From 8e9f368158f44f84198231de857b78e5f0279bcb Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Thu, 11 Jun 2026 20:43:44 +0800 Subject: [PATCH 1/7] feat(ts): add protoc-gen-ts-tableau-loader code generator Introduce a TypeScript tableau loader generator (protobuf-es backed), mirroring the existing Go/C#/C++ loaders, together with its test harness and the toolchain/CI plumbing needed to build and test it. Generator (cmd/protoc-gen-ts-tableau-loader): main/messager/hub plugin entrypoints and the embedded runtime (load/messager/util .pc.ts + hub template); typed map getters, Hub with name filter, JSON/BIN formats and patch loading (MERGE/REPLACE, LoadMode ALL/ONLY_MAIN/ONLY_PATCH); helper package for naming/type mapping; expose the TS option in internal/options. Test harness (test/ts-tableau-loader): buf.gen + tsconfig + package manifests and generated loaders; smoke.ts covering hub load, nested map getters, string/int keys, filter, BIN load, patch MERGE/REPLACE golden compare, ONLY_MAIN/ONLY_PATCH modes and BIN load failure. Toolchain & CI: devcontainer + make.py install Node via NodeSource (apt/dnf) and brew node@, pin NODE_VERSION (major) in versions.env; add testing-ts/release-ts GitHub workflows; update test_make.py, README and CLAUDE.md accordingly. --- .devcontainer/Dockerfile | 15 +- .devcontainer/postcreate-banner.sh | 3 +- .devcontainer/versions.env | 3 + .github/workflows/release-ts.yml | 64 ++ .github/workflows/testing-ts.yml | 68 +++ .gitignore | 1 + CLAUDE.md | 3 + README.md | 8 + _lab/ts/buf.gen.yaml | 2 +- _lab/ts/package.json | 2 +- _lab/ts/src/poc.ts | 2 +- cmd/protoc-gen-ts-tableau-loader/embed.go | 43 ++ .../embed/load.pc.ts | 267 ++++++++ .../embed/messager.pc.ts | 52 ++ .../embed/templates/hub.pc.ts.tpl | 114 ++++ .../embed/util.pc.ts | 178 ++++++ .../helper/helper.go | 248 ++++++++ cmd/protoc-gen-ts-tableau-loader/hub.go | 47 ++ cmd/protoc-gen-ts-tableau-loader/main.go | 51 ++ cmd/protoc-gen-ts-tableau-loader/messager.go | 309 ++++++++++ make.py | 136 ++++- test/ts-tableau-loader/buf.gen.yaml | 33 + test/ts-tableau-loader/package-lock.json | 575 ++++++++++++++++++ test/ts-tableau-loader/package.json | 20 + .../tableau/barrel/base.pc.ts | 12 + .../tableau/barrel/protoconf.pc.ts | 18 + .../ts-tableau-loader/tableau/hero_conf.pc.ts | 101 +++ test/ts-tableau-loader/tableau/hub.pc.ts | 216 +++++++ .../tableau/index_conf.pc.ts | 266 ++++++++ .../ts-tableau-loader/tableau/item_conf.pc.ts | 51 ++ test/ts-tableau-loader/tableau/load.pc.ts | 274 +++++++++ test/ts-tableau-loader/tableau/messager.pc.ts | 59 ++ .../tableau/patch_conf.pc.ts | 139 +++++ .../ts-tableau-loader/tableau/test_conf.pc.ts | 227 +++++++ test/ts-tableau-loader/tableau/util.pc.ts | 185 ++++++ test/ts-tableau-loader/tests/smoke.ts | 171 ++++++ test/ts-tableau-loader/tsconfig.json | 13 + test_make.py | 90 ++- 38 files changed, 4043 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/release-ts.yml create mode 100644 .github/workflows/testing-ts.yml create mode 100644 cmd/protoc-gen-ts-tableau-loader/embed.go create mode 100644 cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts create mode 100644 cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts create mode 100644 cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl create mode 100644 cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts create mode 100644 cmd/protoc-gen-ts-tableau-loader/helper/helper.go create mode 100644 cmd/protoc-gen-ts-tableau-loader/hub.go create mode 100644 cmd/protoc-gen-ts-tableau-loader/main.go create mode 100644 cmd/protoc-gen-ts-tableau-loader/messager.go create mode 100644 test/ts-tableau-loader/buf.gen.yaml create mode 100644 test/ts-tableau-loader/package-lock.json create mode 100644 test/ts-tableau-loader/package.json create mode 100644 test/ts-tableau-loader/tableau/barrel/base.pc.ts create mode 100644 test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/hero_conf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/hub.pc.ts create mode 100644 test/ts-tableau-loader/tableau/index_conf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/item_conf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/load.pc.ts create mode 100644 test/ts-tableau-loader/tableau/messager.pc.ts create mode 100644 test/ts-tableau-loader/tableau/patch_conf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/test_conf.pc.ts create mode 100644 test/ts-tableau-loader/tableau/util.pc.ts create mode 100644 test/ts-tableau-loader/tests/smoke.ts create mode 100644 test/ts-tableau-loader/tsconfig.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 829380c..da50386 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,11 +2,11 @@ # tableauio/loader devcontainer # # Single-stage, multi-arch (amd64 + arm64) image bringing the full -# C++/Go/.NET toolchain plus protobuf at the exact versions CI uses. +# C++/Go/.NET/Node toolchain plus protobuf at the exact versions CI uses. # # All version pins are read from ./versions.env (the single source of # truth shared with make.py and the CI workflows). To bump Go / buf / -# protobuf / .NET / vcpkg-baseline, edit that file — not this one. +# protobuf / .NET / Node / vcpkg-baseline, edit that file — not this one. # # Build context is .devcontainer/ (the directory containing this file), # so `COPY versions.env ...` resolves directly. @@ -209,8 +209,8 @@ ln -s /opt/vcpkg/active/tools/protobuf/protoc /usr/local/bin/protoc EOF # --------------------------------------------------------------------------- -# .NET SDK — apt-based install from the official Microsoft repository. -# Version is read from /opt/versions.env. +# .NET SDK + Node.js — apt-based installs from the official Microsoft and +# NodeSource repositories. Versions are read from /opt/versions.env. # apt-get clean + rm /var/lib/apt/lists at the end keeps the layer small. # --------------------------------------------------------------------------- RUN <&1)" printf ' protoc: %s\n' "$(protoc --version)" printf ' dotnet: %s\n' "$(dotnet --version)" +printf ' node: %s\n' "$(node --version)" diff --git a/.devcontainer/versions.env b/.devcontainer/versions.env index 893983e..169a1b7 100644 --- a/.devcontainer/versions.env +++ b/.devcontainer/versions.env @@ -61,6 +61,9 @@ LEGACY_V3_VCPKG_BASELINE_COMMIT=6245ce44a03f04d19be125ab1bbab578d0933e85 # CI uses `${DOTNET_VERSION}.x` with actions/setup-dotnet. DOTNET_VERSION=8.0 +# Node.js LTS major. NodeSource apt repo is `setup_${NODE_VERSION}.x`. +NODE_VERSION=20 + # CMake version installed by `make.py setup` on Windows (the devcontainer # base image already ships a recent cmake; macOS/Linux use system cmake). CMAKE_VERSION=3.31.8 diff --git a/.github/workflows/release-ts.yml b/.github/workflows/release-ts.yml new file mode 100644 index 0000000..a27fffd --- /dev/null +++ b/.github/workflows/release-ts.yml @@ -0,0 +1,64 @@ +name: Release TypeScript + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + release: + name: Release cmd/protoc-gen-ts-tableau-loader + runs-on: ubuntu-latest + if: startsWith(github.event.release.tag_name, + 'cmd/protoc-gen-ts-tableau-loader/') + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: linux + goarch: arm64 + - goos: windows + goarch: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Download dependencies + run: | + cd cmd/protoc-gen-ts-tableau-loader + go mod download + - name: Prepare build directory + run: | + mkdir -p build/ + cp README.md build/ + cp LICENSE build/ + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + cd cmd/protoc-gen-ts-tableau-loader + go build -trimpath -o $GITHUB_WORKSPACE/build + - name: Create package + id: package + run: | + PACKAGE_NAME=protoc-gen-ts-tableau-loader.${GITHUB_REF#refs/tags/cmd/protoc-gen-ts-tableau-loader/}.${{ matrix.goos }}.${{ matrix.goarch }}.tar.gz + tar -czvf $PACKAGE_NAME -C build . + echo ::set-output name=name::${PACKAGE_NAME} + - name: Upload asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./${{ steps.package.outputs.name }} + asset_name: ${{ steps.package.outputs.name }} + asset_content_type: application/gzip diff --git a/.github/workflows/testing-ts.yml b/.github/workflows/testing-ts.yml new file mode 100644 index 0000000..8ab2a8b --- /dev/null +++ b/.github/workflows/testing-ts.yml @@ -0,0 +1,68 @@ +name: Testing TypeScript + +# Trigger on pushes, PRs (excluding documentation changes), and nightly. +on: + push: + branches: [master, main] + pull_request: + schedule: + - cron: 0 0 * * * # daily at 00:00 + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + # Supported active Node.js LTS lines plus current. The devcontainer + # and make.py pin NODE_VERSION (loaded below) for local parity; CI + # additionally fans out across majors to catch ESM/tsx regressions. + node-version: ["18", "20", "22"] + + name: test (${{ matrix.os }}, node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Read pinned versions + uses: ./.github/actions/load-versions + + - name: Install Go + # buf-generate runs the Go protoc plugins (incl. + # protoc-gen-ts-tableau-loader) via `go run`, so Go is required even + # though the harness under test is TypeScript. + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + cache: true + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: test/ts-tableau-loader/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install Buf + run: python3 make.py setup --lang go + + - name: Test + # `make.py test --lang ts` runs `buf generate` (Go plugins + protobuf-es + # remote plugin), `npm ci`, `npm run check` (tsc --noEmit type check), + # then `npm run smoke` (tsx tests/smoke.ts). + run: python3 make.py test --lang ts diff --git a/.gitignore b/.gitignore index 58e3329..7d8c94f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ _lab/ts/src/protoconf coverage.txt !test/testdata/bin/ test/csharp-tableau-loader/protoconf +test/ts-tableau-loader/protoconf # C# Dev Kit language service cache (VS Code) *.lscache diff --git a/CLAUDE.md b/CLAUDE.md index 25bd1e3..28c665d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,9 @@ python3 make.py test --lang cpp --protobuf-version 3.21.12 # legacy v # C# python3 make.py test --lang csharp # full python3 make.py test --lang csharp -k HubTest.Load # FullyQualifiedName~HubTest.Load + +# TypeScript +python3 make.py test --lang ts # npm install + generate + test ``` GoogleTest is fetched via CMake `FetchContent` — no manual install. diff --git a/README.md b/README.md index 6deee7e..71dbf91 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,18 @@ Release C# version + + TypeScript + + Release TypeScript version + Release Release Go Release C++ Release C# + Release TypeScript Testing @@ -40,6 +46,7 @@ Testing Go Testing C++ Testing C# + Testing TypeScript Testing make.py @@ -54,6 +61,7 @@ | `protoc-gen-go-tableau-loader` | Go | `*.pc.go` | | `protoc-gen-cpp-tableau-loader` | C++17 | `*.pc.h` / `*.pc.cc` | | `protoc-gen-csharp-tableau-loader` | C# (Unity 2022.3 LTS / .NET 8) | `*.pc.cs` | +| `protoc-gen-ts-tableau-loader` | TypeScript (ESM, protobuf-es) | `*.pc.ts` | ## Quick start diff --git a/_lab/ts/buf.gen.yaml b/_lab/ts/buf.gen.yaml index 2a7518f..88046fd 100644 --- a/_lab/ts/buf.gen.yaml +++ b/_lab/ts/buf.gen.yaml @@ -1,6 +1,6 @@ version: v2 # PoC: mirror the existing per-language buf.gen.yaml pattern, where a "base" -# plugin emits the message types and (later) protoc-gen-tableau-ts wraps them +# plugin emits the message types and (later) protoc-gen-ts-tableau-loader wraps them # into Messager/Hub/index code — exactly like protocolbuffers/go + # protoc-gen-go-tableau-loader on the Go side. # diff --git a/_lab/ts/package.json b/_lab/ts/package.json index af1a74c..6f2e6c4 100644 --- a/_lab/ts/package.json +++ b/_lab/ts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "version": "1.0.0", - "description": "PoC: validate protobuf-es as the codegen base for protoc-gen-tableau-ts", + "description": "PoC: validate protobuf-es as the codegen base for protoc-gen-ts-tableau-loader", "scripts": { "generate": "buf generate ../../test", "poc": "tsx src/poc.ts" diff --git a/_lab/ts/src/poc.ts b/_lab/ts/src/poc.ts index 268574d..3ba3282 100644 --- a/_lab/ts/src/poc.ts +++ b/_lab/ts/src/poc.ts @@ -1,7 +1,7 @@ /** * PoC — validate that protobuf-es (@bufbuild/protobuf) can faithfully consume * the protojson that tableau (github.com/tableauio/tableau) emits, so it can - * serve as the codegen base for `protoc-gen-tableau-ts`. + * serve as the codegen base for `protoc-gen-ts-tableau-loader`. * * Run: npm run generate && npm run poc * diff --git a/cmd/protoc-gen-ts-tableau-loader/embed.go b/cmd/protoc-gen-ts-tableau-loader/embed.go new file mode 100644 index 0000000..1b84c30 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed.go @@ -0,0 +1,43 @@ +package main + +import ( + "embed" + "path" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "google.golang.org/protobuf/compiler/protogen" +) + +//go:embed embed/* +var efs embed.FS + +// generateEmbed generates the runtime library files (util/load/messager/...) +// by copying the embedded TypeScript sources verbatim. The templates +// subdirectory is skipped since it is consumed by the hub generator. +func generateEmbed(gen *protogen.Plugin) { + entries, err := efs.ReadDir("embed") + if err != nil { + panic(err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + + g := gen.NewGeneratedFile(entry.Name(), "") + helper.GenerateFileHeader(gen, nil, g, version) + // refer: [embed: embed path on different OS cannot open file](https://github.com/golang/go/issues/45230) + content, err := efs.ReadFile(path.Join("embed", entry.Name())) + if err != nil { + panic(err) + } + // Rewrite the tableau extension descriptor import to honor pb_path: the + // runtime sources are authored assuming the base output is one level up + // ("../tableau/protobuf/..."); rebase it onto the configured pb_path. + text := strings.ReplaceAll(string(content), + `"../tableau/protobuf/tableau_pb.js"`, + `"`+pbImportPath+`/tableau/protobuf/tableau_pb.js"`) + g.P(text) + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts new file mode 100644 index 0000000..2e185b5 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/load.pc.ts @@ -0,0 +1,267 @@ +import { readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + create, + fromBinary, + fromJson, + type DescMessage, + type JsonValue, + type Message, + type MessageShape, +} from "@bufbuild/protobuf"; +import * as tableaupb from "../tableau/protobuf/tableau_pb.js"; +import { + Format, + format2Ext, + getFormat, + getSheetPatch, + patchMessage, +} from "./util.pc.js"; + +export { Format } from "./util.pc.js"; + +/** + * ReadFunc reads a config file and returns its content. + */ +export type ReadFunc = (path: string) => Uint8Array; + +/** + * LoadFunc loads a messager's content based on the given descriptor, path, + * format, and options. + */ +export type LoadFunc = ( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +) => Message; + +/** + * LoadMode controls patch loading behavior. + */ +export enum LoadMode { + /** Load all related files (main + patches). Default. */ + ALL = "all", + /** Only load the main file. */ + ONLY_MAIN = "only_main", + /** Only load the patch files. */ + ONLY_PATCH = "only_patch", +} + +/** + * BaseOptions is the common options for both global-level and messager-level options. + */ +export interface BaseOptions { + /** Whether to ignore unknown JSON fields during parsing. */ + ignoreUnknownFields?: boolean; + /** Specify the directory paths for config patching. */ + patchDirs?: string[]; + /** Specify the loading mode for config patching. Default is LoadMode.ALL. */ + mode?: LoadMode; + /** Custom read function to read a config file's content. Default is readFileSync. */ + readFunc?: ReadFunc; + /** Custom load function to load a messager's content. Default is loadMessager. */ + loadFunc?: LoadFunc; +} + +/** + * MessagerOptions defines the options for loading a single messager. + */ +export interface MessagerOptions extends BaseOptions { + /** + * Path maps the messager to a corresponding config file path. If specified, + * then the main messager will be parsed from the file directly, other than + * the specified load dir. + */ + path?: string; + /** + * PatchPaths maps the messager to one or multiple corresponding patch file + * paths. If specified, then the main messager will be patched. + */ + patchPaths?: string[]; +} + +/** + * Options is the global-level options, which contains both global-level and + * messager-level options. + */ +export interface Options extends BaseOptions { + /** + * messagerOptions maps each messager name to a MessagerOptions. If specified, + * then the messager will be parsed with the given options directly. + */ + messagerOptions?: { [name: string]: MessagerOptions }; +} + +/** + * parseMessagerOptionsByName parses messager options with both global-level and + * messager-level options taken into consideration. + */ +export function parseMessagerOptionsByName(options: Options, name: string): MessagerOptions { + const mopts: MessagerOptions = options.messagerOptions?.[name] + ? { ...options.messagerOptions[name] } + : {}; + mopts.ignoreUnknownFields ??= options.ignoreUnknownFields; + mopts.patchDirs ??= options.patchDirs; + mopts.mode ??= options.mode; + mopts.readFunc ??= options.readFunc; + mopts.loadFunc ??= options.loadFunc; + return mopts; +} + +function defaultRead(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +/** + * loadMessager loads a protobuf message from the specified file path and format. + */ +export function loadMessager( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +): Message { + const readFunc = options?.readFunc ?? defaultRead; + let content: Uint8Array; + try { + content = readFunc(path); + } catch (e) { + throw new Error(`failed to read ${path}`, { cause: e }); + } + return unmarshal(content, desc, fmt, options); +} + +/** + * loadMessagerInDir loads a protobuf message from the specified directory and + * format. It resolves the file path based on the message descriptor name. + */ +export function loadMessagerInDir( + desc: Desc, + dir: string, + fmt: Format, + options?: MessagerOptions, +): MessageShape { + let path = ""; + if (options?.path) { + path = options.path; + fmt = getFormat(path); + } + if (path === "") { + path = join(dir, desc.name + format2Ext(fmt)); + } + const sheetPatch = getSheetPatch(desc); + let msg: Message; + if (sheetPatch !== tableaupb.Patch.NONE) { + msg = loadMessagerWithPatch(desc, path, fmt, sheetPatch, options); + } else { + const loadFunc = options?.loadFunc ?? loadMessager; + msg = loadFunc(desc, path, fmt, options); + } + return msg as MessageShape; +} + +/** + * loadMessagerWithPatch loads a protobuf message with patch support. + */ +export function loadMessagerWithPatch( + desc: DescMessage, + path: string, + fmt: Format, + patch: tableaupb.Patch, + options?: MessagerOptions, +): Message { + const mode = options?.mode ?? LoadMode.ALL; + const loadFunc = options?.loadFunc ?? loadMessager; + if (mode === LoadMode.ONLY_MAIN) { + // Ignore patch files when LoadMode.ONLY_MAIN specified. + return loadFunc(desc, path, fmt, options); + } + + let patchPaths: string[] = []; + const explicitPaths = options?.patchPaths && options.patchPaths.length > 0; + if (explicitPaths) { + // PatchPaths takes precedence over PatchDirs. + patchPaths = [...options!.patchPaths!]; + } else if (options?.patchDirs) { + const filename = desc.name + format2Ext(fmt); + for (const patchDir of options.patchDirs) { + patchPaths.push(join(patchDir, filename)); + } + } + + // Filter out non-existing patch files when relying on PatchDirs. + let existedPatchPaths: string[]; + if (explicitPaths) { + // Explicit paths are kept as-is; loadFunc surfaces errors if missing. + existedPatchPaths = patchPaths; + } else { + existedPatchPaths = patchPaths.filter((p) => existsSync(p)); + } + + if (existedPatchPaths.length === 0) { + if (mode === LoadMode.ONLY_PATCH) { + // Return empty message when LoadMode.ONLY_PATCH specified but no valid + // patch file provided. + return create(desc); + } + // No valid patch path provided, then just load from the "main" file. + return loadFunc(desc, path, fmt, options); + } + + switch (patch) { + case tableaupb.Patch.REPLACE: { + // Just use the last "patch" file. + const patchPath = existedPatchPaths[existedPatchPaths.length - 1]!; + return loadFunc(desc, patchPath, getFormat(patchPath), options); + } + case tableaupb.Patch.MERGE: { + let msg: Message; + if (mode !== LoadMode.ONLY_PATCH) { + // Load msg from the "main" file. + msg = loadFunc(desc, path, fmt, options); + } else { + msg = create(desc); + } + for (const patchPath of existedPatchPaths) { + const patchMsg = loadFunc(desc, patchPath, getFormat(patchPath), options); + patchMessage(msg, patchMsg, desc); + } + return msg; + } + default: + throw new Error(`unknown patch type: ${patch}`); + } +} + +/** + * unmarshal parses the given byte content into a protobuf message based on the + * specified format. + */ +export function unmarshal( + content: Uint8Array, + desc: DescMessage, + fmt: Format, + options?: MessagerOptions, +): Message { + switch (fmt) { + case Format.JSON: + try { + const json = JSON.parse(new TextDecoder().decode(content)) as JsonValue; + return fromJson(desc, json, { + ignoreUnknownFields: options?.ignoreUnknownFields ?? false, + }); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.json`, { cause: e }); + } + case Format.BIN: + try { + return fromBinary(desc, content); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.binpb`, { cause: e }); + } + default: + throw new Error(`unknown format: ${fmt}`); + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts new file mode 100644 index 0000000..0aa9161 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/messager.pc.ts @@ -0,0 +1,52 @@ +import { type Message } from "@bufbuild/protobuf"; +import { Format } from "./util.pc.js"; +import { type MessagerOptions } from "./load.pc.js"; +// Type-only import to describe the hub passed to processAfterLoadAll. Importing +// only the type avoids a runtime cycle with the generated hub module. +import type { Hub } from "./hub.pc.js"; + +/** + * Stats contains statistics info about loading. + */ +export interface Stats { + /** Total load time consuming, in milliseconds. */ + durationMs: number; +} + +/** + * Messager is the base class for all generated configuration messagers. + * It is designed for three goals: + * 1. Easy use: simple yet powerful accessors. + * 2. Elegant API: concise and clean functions. + * 3. Extensibility: Map, OrderedMap, Index, OrderedIndex... + */ +export abstract class Messager { + protected loadStats: Stats = { durationMs: 0 }; + + /** getStats returns the loading stats info. */ + getStats(): Stats { + return this.loadStats; + } + + /** name returns the messager's message name. */ + abstract name(): string; + + /** load fills message from file in the specified directory and format. Throws on failure. */ + abstract load(dir: string, fmt: Format, options?: MessagerOptions): void; + + /** message returns the inner protobuf message data. */ + message(): Message | undefined { + return undefined; + } + + /** processAfterLoad is invoked after this messager is loaded. Throws on failure. */ + protected processAfterLoad(): void {} + + /** processAfterLoadAll is invoked after all messagers are loaded. Throws on failure. */ + processAfterLoadAll(_hub: Hub): void {} +} + +/** + * MessagerCtor is the constructor type for a concrete Messager. + */ +export type MessagerCtor = new () => Messager; diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl b/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl new file mode 100644 index 0000000..28a0d4a --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/templates/hub.pc.ts.tpl @@ -0,0 +1,114 @@ +import { Messager, type MessagerCtor } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { type Options, parseMessagerOptionsByName } from "./load.pc.js"; +{{- range .Imports }} +import { {{ range $i, $n := .Names }}{{ if $i }}, {{ end }}{{ $n }}{{ end }} } from "./{{ .Module }}.js"; +{{- end }} + +/** + * HubOptions is the options for Hub. + */ +export interface HubOptions { + /** + * filter can only filter in certain specific messagers based on the + * condition that you provide. + */ + filter?: (name: string) => boolean; +} + +/** + * Registry manages the registration of all messager generators. + */ +export class Registry { + static readonly registrar: Map = new Map(); + + /** register registers a messager constructor. */ + static register(ctor: MessagerCtor): void { + Registry.registrar.set(new ctor().name(), ctor); + } + + /** init registers all generated messagers. */ + static init(): void { +{{- range .Messagers }} + Registry.register({{ . }}); +{{- end }} + } +} + +Registry.init(); + +/** + * Hub is the messager manager. It manages loading, accessing, and storing + * all configuration messagers. + */ +export class Hub { + private messagerMap: Map = new Map(); + private lastLoadedTime: Date | undefined; + private readonly options?: HubOptions; + + constructor(options?: HubOptions) { + this.options = options; + } + + /** + * load fills messages from files in the specified directory and format. + * Throws an Error (with the underlying cause chained) on failure. + */ + load(dir: string, fmt: Format, options?: Options): void { + const messagerMap = this.newMessagerMap(); + const opts = options ?? {}; + for (const [name, messager] of messagerMap) { + try { + messager.load(dir, fmt, parseMessagerOptionsByName(opts, name)); + } catch (e) { + throw new Error(`load ${name} failed`, { cause: e }); + } + } + const tmpHub = new Hub(); + tmpHub.setMessagerMap(messagerMap); + for (const [name, messager] of messagerMap) { + try { + messager.processAfterLoadAll(tmpHub); + } catch (e) { + throw new Error(`hub call processAfterLoadAll failed, messager: ${name}`, { cause: e }); + } + } + this.setMessagerMap(messagerMap); + } + + /** getMessagerMap returns the current messager map. */ + getMessagerMap(): Map { + return this.messagerMap; + } + + /** setMessagerMap sets the messager map. */ + setMessagerMap(map: Map): void { + this.messagerMap = map; + this.lastLoadedTime = new Date(); + } + + /** getMessager returns the messager by name. */ + getMessager(name: string): Messager | undefined { + return this.messagerMap.get(name); + } + + /** getLastLoadedTime returns the time when hub's messager map was last set. */ + getLastLoadedTime(): Date | undefined { + return this.lastLoadedTime; + } + + private newMessagerMap(): Map { + const messagerMap = new Map(); + for (const [name, ctor] of Registry.registrar) { + if (!this.options?.filter || this.options.filter(name)) { + messagerMap.set(name, new ctor()); + } + } + return messagerMap; + } +{{ range .Messagers }} + /** get{{ . }} returns the {{ . }} messager. */ + get{{ . }}(): {{ . }} | undefined { + return this.messagerMap.get("{{ . }}") as {{ . }} | undefined; + } +{{ end }}} diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts new file mode 100644 index 0000000..d1db8d8 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts @@ -0,0 +1,178 @@ +import { clone, getExtension, type DescField, type DescMessage, type Message } from "@bufbuild/protobuf"; +import * as tableaupb from "../tableau/protobuf/tableau_pb.js"; + +/** + * Format specifies the format of the configuration file. + */ +export enum Format { + UNKNOWN = "unknown", + JSON = "json", + BIN = "bin", +} + +const UNKNOWN_EXT = ".unknown"; +const JSON_EXT = ".json"; +const BIN_EXT = ".binpb"; + +/** + * getFormat returns the Format determined by the file extension of the given path. + */ +export function getFormat(path: string): Format { + if (path.endsWith(JSON_EXT)) return Format.JSON; + if (path.endsWith(BIN_EXT)) return Format.BIN; + return Format.UNKNOWN; +} + +/** + * format2Ext returns the file extension corresponding to the given format. + */ +export function format2Ext(fmt: Format): string { + switch (fmt) { + case Format.JSON: + return JSON_EXT; + case Format.BIN: + return BIN_EXT; + default: + return UNKNOWN_EXT; + } +} + +// AnyMessage is a structural view over a protobuf-es message used by the +// reflection-based patch routines below. +type AnyMessage = Record; + +/** + * getSheetPatch returns the sheet-level patch type for a message descriptor. + */ +export function getSheetPatch(desc: DescMessage): tableaupb.Patch { + const opts = desc.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const ws = getExtension(opts, tableaupb.worksheet); + return ws?.patch ?? tableaupb.Patch.NONE; +} + +function getFieldPatch(fd: DescField): tableaupb.Patch { + const opts = fd.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const fieldOpts = getExtension(opts, tableaupb.field); + return fieldOpts?.prop?.patch ?? tableaupb.Patch.NONE; +} + +function isFieldPopulated(msg: AnyMessage, fd: DescField): boolean { + const v = msg[fd.localName]; + if (v === undefined || v === null) return false; + switch (fd.fieldKind) { + case "list": + return Array.isArray(v) && v.length > 0; + case "map": + return typeof v === "object" && Object.keys(v as object).length > 0; + case "message": + return true; + default: + if (typeof v === "bigint") return v !== 0n; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") return v.length > 0; + if (typeof v === "boolean") return v; + if (v instanceof Uint8Array) return v.length > 0; + return true; + } +} + +function clearField(msg: AnyMessage, fd: DescField): void { + switch (fd.fieldKind) { + case "list": + msg[fd.localName] = []; + break; + case "map": + msg[fd.localName] = {}; + break; + default: + delete msg[fd.localName]; + break; + } +} + +/** + * patchMessage patches src into dst, which must be messages with the same descriptor. + * + * Default mechanism: + * - scalar: populated scalar fields in src are copied to dst. + * - message: populated singular messages in src are merged into dst recursively, + * or replace dst message if PATCH_REPLACE is specified for the field. + * - list: src elements are appended to dst, or replace dst list on PATCH_REPLACE. + * - map: src entries are merged into dst, or replace dst map on PATCH_REPLACE. + */ +export function patchMessage(dst: Message, src: Message, desc: DescMessage): void { + patchMessageInternal(dst as unknown as AnyMessage, src as unknown as AnyMessage, desc); +} + +function patchMessageInternal(dst: AnyMessage, src: AnyMessage, desc: DescMessage): void { + for (const fd of desc.fields) { + if (!isFieldPopulated(src, fd)) continue; + if (getFieldPatch(fd) === tableaupb.Patch.REPLACE) { + clearField(dst, fd); + } + switch (fd.fieldKind) { + case "map": + patchMap(dst, src, fd); + break; + case "list": + patchList(dst, src, fd); + break; + case "message": { + const name = fd.localName; + const srcChild = src[name] as AnyMessage; + const dstChild = dst[name] as AnyMessage | undefined; + if (dstChild === undefined || dstChild === null) { + dst[name] = clone(fd.message, srcChild as never); + } else { + patchMessageInternal(dstChild, srcChild, fd.message); + } + break; + } + default: + dst[fd.localName] = src[fd.localName]; + break; + } + } +} + +function patchList(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcList = src[fd.localName] as unknown[]; + let dstList = dst[fd.localName] as unknown[] | undefined; + if (!Array.isArray(dstList)) { + dstList = []; + dst[fd.localName] = dstList; + } + const isMessage = fd.fieldKind === "list" && fd.listKind === "message"; + for (const item of srcList) { + if (isMessage && fd.message) { + dstList.push(clone(fd.message, item as never)); + } else { + dstList.push(item); + } + } +} + +function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcMap = src[fd.localName] as Record; + let dstMap = dst[fd.localName] as Record | undefined; + if (typeof dstMap !== "object" || dstMap === null) { + dstMap = {}; + dst[fd.localName] = dstMap; + } + const isMessageValue = fd.fieldKind === "map" && fd.mapKind === "message"; + for (const [k, v] of Object.entries(srcMap)) { + if (isMessageValue && fd.message) { + const existing = dstMap[k] as AnyMessage | undefined; + if (existing !== undefined && existing !== null) { + // NOTE: this MERGES into the existing value (differs from a simple replace). + patchMessageInternal(existing, v as AnyMessage, fd.message); + } else { + dstMap[k] = clone(fd.message, v as never); + } + } else { + dstMap[k] = v; + } + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go new file mode 100644 index 0000000..53903fb --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go @@ -0,0 +1,248 @@ +package helper + +import ( + "fmt" + "strings" + + "github.com/iancoleman/strcase" + "github.com/tableauio/tableau/proto/tableaupb" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// GenerateFileHeader writes the auto-generated file header comment block, +// including version info and source path. If file is non-nil, the source path +// (or deprecation notice) is included. +func GenerateFileHeader(gen *protogen.Plugin, file *protogen.File, g *protogen.GeneratedFile, version string) { + g.P("// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT.") + g.P("// versions:") + g.P("// - protoc-gen-ts-tableau-loader v", version) + g.P("// - protoc ", protocVersion(gen)) + if file != nil { + if file.Proto.GetOptions().GetDeprecated() { + g.P("// ", file.Desc.Path(), " is a deprecated file.") + } else { + g.P("// source: ", file.Desc.Path()) + } + } + g.P("/* eslint-disable */") + g.P() +} + +// protocVersion returns the protoc compiler version string (e.g. "v3.19.3"). +func protocVersion(gen *protogen.Plugin) string { + v := gen.Request.GetCompilerVersion() + if v == nil { + return "(unknown)" + } + var suffix string + if s := v.GetSuffix(); s != "" { + suffix = "-" + s + } + return fmt.Sprintf("v%d.%d.%d%s", v.GetMajor(), v.GetMinor(), v.GetPatch(), suffix) +} + +// MessagerName returns the TypeScript class name for a worksheet message. +// Worksheet messages are always top-level, so this is just the short name. +func MessagerName(md protoreflect.MessageDescriptor) string { + return string(md.Name()) +} + +// LocalTypeName returns the protobuf-es local type name for a message or enum, +// which joins the nested type path with underscores after stripping the package +// (e.g. "protoconf.ThemeConf.Theme" -> "ThemeConf_Theme", +// "protoconf.UseEffect.Type" -> "UseEffect_Type"). Works for top-level and +// nested descriptors alike. +func LocalTypeName(d protoreflect.Descriptor) string { + full := string(d.FullName()) + if pkg := string(d.ParentFile().Package()); pkg != "" { + full = strings.TrimPrefix(full, pkg+".") + } + return strings.ReplaceAll(full, ".", "_") +} + +// LocalSchemaName returns the protobuf-es schema export name for any message, +// top-level or nested (e.g. "protoconf.ThemeConf.Theme" -> "ThemeConf_ThemeSchema"). +func LocalSchemaName(d protoreflect.Descriptor) string { + return LocalTypeName(d) + "Schema" +} + +// PackageAlias converts a protobuf package name into a valid TypeScript +// namespace-import alias by replacing dots with underscores (e.g. "foo.bar" -> +// "foo_bar"). This alias is used as the namespace for a package's barrel import +// (import * as ), so generated loaders qualify types exactly like the +// loaders of other languages do (e.g. protoconf.ItemConf, base.Hero), keeping +// cross-language naming consistent. Empty packages fall back to "pb". +func PackageAlias(pkg string) string { + if pkg == "" { + return "pb" + } + return strings.ReplaceAll(pkg, ".", "_") +} + +// ScalarTSType returns the plain TypeScript type for a protobuf scalar kind. +// ok is false for non-scalar kinds (message/group/enum), letting callers handle +// those via alias-qualified named types instead. +func ScalarTSType(kind protoreflect.Kind) (string, bool) { + switch kind { + case protoreflect.BoolKind: + return "boolean", true + case protoreflect.StringKind: + return "string", true + case protoreflect.BytesKind: + return "Uint8Array", true + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind: + return "number", true + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return "bigint", true + default: + return "", false + } +} + +// MapKey represents a single key component of a (possibly nested) map getter. +type MapKey struct { + // ParamType is the TypeScript getter parameter type (number/bigint/string/boolean). + ParamType string + // IdxKind is the TypeScript index-signature kind of the protobuf-es map + // object (number or string), used to build indexed-access return types. + IdxKind string + // Name is the getter parameter name (deduplicated across nested levels). + Name string + // NeedToString reports whether the parameter must be stringified to index + // the underlying map object (true for 64-bit / bool keys). + NeedToString bool +} + +// IndexExpr returns the expression used to index the underlying map object. +func (k MapKey) IndexExpr() string { + if k.NeedToString { + return k.Name + ".toString()" + } + return k.Name +} + +// MapKeySlice is an ordered collection of MapKey entries. +type MapKeySlice []MapKey + +// AddMapKey appends a new map key, deduplicating the parameter Name across +// nested levels (e.g. "id" -> "id3") so generated getter signatures are valid. +func (s MapKeySlice) AddMapKey(newKey MapKey) MapKeySlice { + if newKey.Name == "" { + newKey.Name = fmt.Sprintf("key%d", len(s)+1) + } + for _, key := range s { + if key.Name == newKey.Name { + newKey.Name = fmt.Sprintf("%s%d", newKey.Name, len(s)+1) + break + } + } + return append(s, newKey) +} + +// GenGetParams generates the getter parameter list (e.g. "id: number, name: string"). +func (s MapKeySlice) GenGetParams() string { + var params []string + for _, key := range s { + params = append(params, key.Name+": "+key.ParamType) + } + return strings.Join(params, ", ") +} + +// GenGetArguments generates the getter argument list (e.g. "id, name"). +func (s MapKeySlice) GenGetArguments() string { + var args []string + for _, key := range s { + args = append(args, key.Name) + } + return strings.Join(args, ", ") +} + +// ParseMapKey returns the MapKey metadata (param type, index kind, stringify +// flag) for a map field's key descriptor (fd must be a map key descriptor). +func ParseMapKey(keyFd protoreflect.FieldDescriptor, name string) MapKey { + key := MapKey{Name: name} + switch keyFd.Kind() { + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + key.ParamType = "number" + key.IdxKind = "number" + key.NeedToString = false + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + key.ParamType = "bigint" + key.IdxKind = "string" + key.NeedToString = true + case protoreflect.BoolKind: + key.ParamType = "boolean" + key.IdxKind = "string" + key.NeedToString = true + case protoreflect.StringKind: + key.ParamType = "string" + key.IdxKind = "string" + key.NeedToString = false + default: + // Map keys can only be integral, bool, or string. + key.ParamType = "string" + key.IdxKind = "string" + key.NeedToString = false + } + return key +} + +// ParseMapFieldNameAsFuncParam returns a safe lowerCamelCase getter parameter +// name for a map field key. If the map value is a message type, the first field +// name of the value message is used; otherwise the tableau field option "key" +// is used. +func ParseMapFieldNameAsFuncParam(fd protoreflect.FieldDescriptor) string { + opts := fd.Options().(*descriptorpb.FieldOptions) + fdOpts := proto.GetExtension(opts, tableaupb.E_Field).(*tableaupb.FieldOptions) + name := fdOpts.GetKey() + if fd.MapValue().Kind() == protoreflect.MessageKind { + valueFd := fd.MapValue().Message().Fields().Get(0) + name = string(valueFd.Name()) + } + if name == "" { + return "" + } + return escapeIdentifier(strcase.ToLowerCamel(name)) +} + +// FieldLocalName returns the protobuf-es local (JS property) name for a field, +// which is the lowerCamelCase form of the protobuf field name. +func FieldLocalName(fd protoreflect.FieldDescriptor) string { + return strcase.ToLowerCamel(string(fd.Name())) +} + +// tsReservedWords are TypeScript/JavaScript reserved words that cannot be used +// as bare identifiers for function parameters. +var tsReservedWords = map[string]bool{ + "break": true, "case": true, "catch": true, "class": true, "const": true, + "continue": true, "debugger": true, "default": true, "delete": true, "do": true, + "else": true, "enum": true, "export": true, "extends": true, "false": true, + "finally": true, "for": true, "function": true, "if": true, "import": true, + "in": true, "instanceof": true, "new": true, "null": true, "return": true, + "super": true, "switch": true, "this": true, "throw": true, "true": true, + "try": true, "typeof": true, "var": true, "void": true, "while": true, + "with": true, "let": true, "static": true, "yield": true, "await": true, +} + +// escapeIdentifier escapes a TypeScript reserved word by appending an +// underscore, producing a valid identifier. +func escapeIdentifier(name string) string { + if tsReservedWords[name] { + return name + "_" + } + return name +} + +// Indent returns a string of 2*depth spaces, used for indenting generated +// TypeScript code blocks at the specified nesting depth. +func Indent(depth int) string { + return strings.Repeat(" ", depth) +} diff --git a/cmd/protoc-gen-ts-tableau-loader/hub.go b/cmd/protoc-gen-ts-tableau-loader/hub.go new file mode 100644 index 0000000..45d9e48 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/hub.go @@ -0,0 +1,47 @@ +package main + +import ( + "text/template" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/xproto" + "google.golang.org/protobuf/compiler/protogen" +) + +var tpl = template.Must(template.New("").ParseFS(efs, "embed/templates/*")) + +// importEntry describes a single ES module import in the generated hub. +type importEntry struct { + Names []string // messager class names imported from the module + Module string // module specifier (without leading "./" or trailing ".js") +} + +// hubData is the template data for the generated hub file. +type hubData struct { + Imports []importEntry + Messagers []string +} + +// generateHub generates the hub file (registry + hub manager). +func generateHub(gen *protogen.Plugin) { + filename := "hub.pc.ts" + g := gen.NewGeneratedFile(filename, "") + helper.GenerateFileHeader(gen, nil, g, version) + + pfs := xproto.ParseProtoFiles(gen) + var data hubData + for _, pf := range pfs { + if len(pf.Messagers) == 0 { + continue + } + data.Imports = append(data.Imports, importEntry{ + Names: pf.Messagers, + Module: pf.Name + ".pc", + }) + data.Messagers = append(data.Messagers, pf.Messagers...) + } + + if err := tpl.Lookup("hub.pc.ts.tpl").Execute(g, data); err != nil { + panic(err) + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/main.go b/cmd/protoc-gen-ts-tableau-loader/main.go new file mode 100644 index 0000000..9e3b5fc --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/tableauio/loader/internal/options" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +const version = "0.1.0" + +// pbImportPath is the relative path from the loader output dir to the +// protobuf-es generated base output dir. It is used to build the imports of +// the base message modules (e.g. "/_pb.js") and the +// tableau extension descriptors. Configurable via the "pb_path" plugin option; +// defaults to ".." (loader output nested one level under the base output). +var pbImportPath = ".." + +func main() { + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + if *showVersion { + fmt.Printf("protoc-gen-ts-tableau-loader %v\n", version) + return + } + + var flags flag.FlagSet + flags.StringVar(&pbImportPath, "pb_path", "..", "relative path from the loader output dir to the protobuf-es base output dir") + + protogen.Options{ + ParamFunc: flags.Set, + }.Run(func(gen *protogen.Plugin) error { + gen.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL | pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS) + gen.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_PROTO2 + gen.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2024 + reg := newBarrelRegistry(gen) + for _, f := range gen.Files { + if !options.NeedGenFile(f) { + continue + } + generateMessager(gen, f, reg) + } + generateHub(gen) + generateEmbed(gen) + generateBarrels(gen, reg) + return nil + }) +} diff --git a/cmd/protoc-gen-ts-tableau-loader/messager.go b/cmd/protoc-gen-ts-tableau-loader/messager.go new file mode 100644 index 0000000..c42aed5 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/messager.go @@ -0,0 +1,309 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/loadutil" + "github.com/tableauio/tableau/proto/tableaupb" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// isWorksheet reports whether a message is a tableau worksheet. +func isWorksheet(message *protogen.Message) bool { + opts := message.Desc.Options().(*descriptorpb.MessageOptions) + worksheet := proto.GetExtension(opts, tableaupb.E_Worksheet).(*tableaupb.WorksheetOptions) + return worksheet != nil +} + +// generateMessager generates a loader file corresponding to the protobuf file. +// Each wrapped class extends the Messager base class. +func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegistry) { + filename := file.GeneratedFilenamePrefix + ".pc.ts" + g := gen.NewGeneratedFile(filename, "") + helper.GenerateFileHeader(gen, file, g, version) + + var messagers []*protogen.Message + for _, message := range file.Messages { + if isWorksheet(message) { + messagers = append(messagers, message) + } + } + + // Runtime imports. + g.P(`import { create } from "@bufbuild/protobuf";`) + g.P(`import { Messager } from "./messager.pc.js";`) + g.P(`import { Format } from "./util.pc.js";`) + g.P(`import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js";`) + // Namespace imports for the protobuf-es generated types, one per proto + // package: the worksheet's own package plus any other package owning a + // map-value message/enum referenced by a getter. Each package is imported + // from its barrel module under an alias equal to the proto package name + // (e.g. import * as protoconf / import * as base), so the loader qualifies + // types exactly like the loaders of other languages (protoconf.ItemConf, + // base.Hero) — consistent cross-language naming, and the alias also sidesteps + // the wrapper-class vs. message-type name collision. + pkgs := newPBPackages(file.GeneratedFilenamePrefix) + pkgs.add(string(file.Desc.Package())) + for _, message := range messagers { + collectValuePackages(message.Desc, pkgs) + } + pkgs.emit(g) + for _, pkg := range pkgs.order { + reg.markReferenced(pkg) + } + g.P() + + for i, message := range messagers { + if i > 0 { + g.P() + } + genMessage(gen, g, message, pkgs) + } +} + +// genMessage generates a single messager class definition. +func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protogen.Message, pkgs *pbPackages) { + md := message.Desc + name := helper.MessagerName(md) + alias := pkgs.aliasOf(md) + schema := alias + "." + helper.LocalSchemaName(md) // runtime schema value + dataType := alias + "." + helper.LocalTypeName(md) // message type + + g.P("/**") + g.P(" * ", name, " is a wrapper around protobuf message ", md.FullName(), ".") + g.P(" */") + g.P("export class ", name, " extends Messager {") + g.P(helper.Indent(1), "private data_: ", dataType, " = create(", schema, ");") + g.P() + + // name() + g.P(helper.Indent(1), "/** name returns the ", name, "'s message name. */") + g.P(helper.Indent(1), "name(): string {") + g.P(helper.Indent(2), "return ", schema, ".name;") + g.P(helper.Indent(1), "}") + g.P() + + // load() + g.P(helper.Indent(1), "/** load loads ", name, "'s content in the given dir, based on format and messager options. Throws on failure. */") + g.P(helper.Indent(1), "load(dir: string, fmt: Format, options?: MessagerOptions): void {") + g.P(helper.Indent(2), "const start = Date.now();") + g.P(helper.Indent(2), "try {") + g.P(helper.Indent(3), "this.data_ = loadMessagerInDir(", schema, ", dir, fmt, options);") + g.P(helper.Indent(2), "} catch (e) {") + g.P(helper.Indent(3), "throw new Error(`failed to load ", name, "`, { cause: e });") + g.P(helper.Indent(2), "}") + g.P(helper.Indent(2), "this.loadStats.durationMs = Date.now() - start;") + g.P(helper.Indent(2), "this.processAfterLoad();") + g.P(helper.Indent(1), "}") + g.P() + + // data() + g.P(helper.Indent(1), "/** data returns the ", name, "'s inner message data. */") + g.P(helper.Indent(1), "data(): ", dataType, " {") + g.P(helper.Indent(2), "return this.data_;") + g.P(helper.Indent(1), "}") + g.P() + + // message() + g.P(helper.Indent(1), "/** message returns the ", name, "'s inner message data. */") + g.P(helper.Indent(1), "override message(): ", dataType, " {") + g.P(helper.Indent(2), "return this.data_;") + g.P(helper.Indent(1), "}") + + // syntactic sugar for accessing map items + genMapGetters(g, md, 1, nil, pkgs) + + g.P("}") +} + +// genMapGetters generates nested map getters (get1/get2/...) for a message. +func genMapGetters(g *protogen.GeneratedFile, md protoreflect.MessageDescriptor, depth int, keys helper.MapKeySlice, pkgs *pbPackages) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if !fd.IsMap() { + continue + } + localName := helper.FieldLocalName(fd) + paramName := helper.ParseMapFieldNameAsFuncParam(fd) + keys = keys.AddMapKey(helper.ParseMapKey(fd.MapKey(), paramName)) + last := keys[len(keys)-1] + + // The value type comes straight from the map value: message/enum values + // use their own package-qualified named type (protoconf.Foo); scalar + // values use the plain TypeScript type. + returnType := mapValueType(fd, pkgs) + " | undefined" + getter := fmt.Sprintf("get%d", depth) + + var access string + if depth == 1 { + access = "this.data_." + localName + "[" + last.IndexExpr() + "]" + } else { + prevArgs := keys[:len(keys)-1].GenGetArguments() + access = fmt.Sprintf("this.get%d(%s)?.%s[%s]", depth-1, prevArgs, localName, last.IndexExpr()) + } + + g.P() + g.P(helper.Indent(1), "/** ", getter, " finds value in the ", loadutil.Ordinal(depth), "-level map; returns undefined if not found. */") + g.P(helper.Indent(1), getter, "(", keys.GenGetParams(), "): ", returnType, " {") + g.P(helper.Indent(2), "return ", access, ";") + g.P(helper.Indent(1), "}") + + if fd.MapValue().Kind() == protoreflect.MessageKind { + genMapGetters(g, fd.MapValue().Message(), depth+1, keys, pkgs) + } + break + } +} + +// mapValueType returns the TypeScript type for a map field's value: a +// package-qualified named type (protoconf.Foo) for message/enum values, or the +// plain scalar type otherwise. +func mapValueType(fd protoreflect.FieldDescriptor, pkgs *pbPackages) string { + v := fd.MapValue() + switch v.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + return pkgs.aliasOf(v.Message()) + "." + helper.LocalTypeName(v.Message()) + case protoreflect.EnumKind: + return pkgs.aliasOf(v.Enum()) + "." + helper.LocalTypeName(v.Enum()) + default: + s, _ := helper.ScalarTSType(v.Kind()) + return s + } +} + +// pbPackages tracks the protobuf packages a single loader file imports and +// emits one namespace import per package, pointing at that package's barrel +// module under the barrel/ subdir: `import * as from +// "barrel/.pc.js"`. The alias is the proto package name (dots -> +// underscores), so generated loaders qualify types exactly like the loaders of +// other languages (e.g. protoconf.ItemConf, base.Hero), keeping cross-language +// naming consistent. +type pbPackages struct { + rel string // relative path prefix from this loader to the barrel dir + order []string // referenced package names, in first-seen order + seen map[string]bool // deduplication set +} + +// newPBPackages creates a registry for a loader whose source-relative output +// prefix is loaderPrefix (e.g. "hero_conf" or "sub/foo"). The number of path +// segments determines how many "../" are needed to reach the loader output +// root; barrel modules live in the barrel/ subdir under that root. +func newPBPackages(loaderPrefix string) *pbPackages { + rel := "./" + if depth := strings.Count(loaderPrefix, "/"); depth > 0 { + rel = strings.Repeat("../", depth) + } + return &pbPackages{rel: rel, seen: map[string]bool{}} +} + +// add registers a proto package (idempotent), preserving first-seen order. +func (p *pbPackages) add(pkg string) { + if p.seen[pkg] { + return + } + p.seen[pkg] = true + p.order = append(p.order, pkg) +} + +// aliasOf returns the namespace alias for a descriptor's owning package. +func (p *pbPackages) aliasOf(d protoreflect.Descriptor) string { + return helper.PackageAlias(string(d.ParentFile().Package())) +} + +// emit writes one namespace import statement per package, in registration order. +func (p *pbPackages) emit(g *protogen.GeneratedFile) { + for _, pkg := range p.order { + alias := helper.PackageAlias(pkg) + g.P(`import * as `, alias, ` from "`, p.rel, `barrel/`, alias, `.pc.js";`) + } +} + +// moduleOf returns the protobuf-es base module specifier for a descriptor's +// parent file (e.g. "../protoconf/base/base_pb.js"), relative to the loader +// output root. +func moduleOf(d protoreflect.Descriptor) string { + return pbImportPath + "/" + strings.TrimSuffix(d.ParentFile().Path(), ".proto") + "_pb.js" +} + +// collectValuePackages walks the same first-map-field chain as genMapGetters and +// registers the owning package of every message/enum map value referenced by a +// getter (including nested messages and those from other proto files), so each +// gets a namespace barrel import. +func collectValuePackages(md protoreflect.MessageDescriptor, pkgs *pbPackages) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if !fd.IsMap() { + continue + } + switch v := fd.MapValue(); v.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + pkgs.add(string(v.Message().ParentFile().Package())) + collectValuePackages(v.Message(), pkgs) + case protoreflect.EnumKind: + pkgs.add(string(v.Enum().ParentFile().Package())) + } + break + } +} + +// barrelRegistry collects, per proto package, all generated files (used to fill +// a package's barrel) and tracks which packages are actually referenced by some +// loader (so only those packages get a barrel emitted). +type barrelRegistry struct { + filesByPkg map[string][]*protogen.File // package -> all files of that package + referenced []string // referenced packages, in first-seen order + seen map[string]bool // deduplication set for referenced +} + +// newBarrelRegistry groups every file known to the generator by proto package. +func newBarrelRegistry(gen *protogen.Plugin) *barrelRegistry { + r := &barrelRegistry{filesByPkg: map[string][]*protogen.File{}, seen: map[string]bool{}} + for _, f := range gen.Files { + pkg := string(f.Desc.Package()) + r.filesByPkg[pkg] = append(r.filesByPkg[pkg], f) + } + return r +} + +// markReferenced records that a package is imported by some loader. +func (r *barrelRegistry) markReferenced(pkg string) { + if r.seen[pkg] { + return + } + r.seen[pkg] = true + r.referenced = append(r.referenced, pkg) +} + +// generateBarrels emits one barrel module per referenced proto package into the +// barrel/ subdir of the loader output root. Each barrel re-exports every +// protobuf-es generated module of that package via `export *`, so a loader can +// import the whole package under a single namespace alias. This is +// collision-free because protobuf guarantees fully-qualified names are unique +// within a package, so the wildcard re-exports never clash. Because the barrel +// sits one level under the loader output root, an extra "../" is prepended to +// each module path (which moduleOf computes relative to that root). +func generateBarrels(gen *protogen.Plugin, reg *barrelRegistry) { + for _, pkg := range reg.referenced { + alias := helper.PackageAlias(pkg) + g := gen.NewGeneratedFile("barrel/"+alias+".pc.ts", "") + helper.GenerateFileHeader(gen, nil, g, version) + g.P(`// Barrel for proto package "`, pkg, `": re-exports every protobuf-es`) + g.P("// generated module of this package, so loaders import the whole package") + g.P("// under one namespace alias (import * as ", alias, `), matching the`) + g.P("// package-qualified naming used by loaders of other languages.") + g.P() + files := append([]*protogen.File(nil), reg.filesByPkg[pkg]...) + sort.Slice(files, func(i, j int) bool { + return files[i].Desc.Path() < files[j].Desc.Path() + }) + for _, f := range files { + g.P(`export * from "../`, moduleOf(f.Desc), `";`) + } + } +} diff --git a/make.py b/make.py index fe08944..7658b35 100644 --- a/make.py +++ b/make.py @@ -3,14 +3,14 @@ make.py — single cross-platform entrypoint for the tableauio/loader repo. Consolidates per-language `buf generate` / `cmake` / `go test` / `dotnet test` -recipes into one Python tool that works identically on +/ `npm test` recipes into one Python tool that works identically on native Windows, macOS, Linux, and inside the devcontainer. Usage (high level): - python3 make.py setup [--lang go|cpp|csharp|all] [--dry-run] - python3 make.py generate --lang go|cpp|csharp - python3 make.py build --lang go|cpp|csharp [build flags] - python3 make.py test --lang go|cpp|csharp [build flags] [-k FILTER] [--smoke] + python3 make.py setup [--lang go|cpp|csharp|ts|all] [--dry-run] + python3 make.py generate --lang go|cpp|csharp|ts + python3 make.py build --lang go|cpp|csharp|ts [build flags] + python3 make.py test --lang go|cpp|csharp|ts [build flags] [-k FILTER] [--smoke] python3 make.py clean [--lang ...] [--all] python3 make.py env python3 make.py --version @@ -142,6 +142,10 @@ def _variant_value(self, suffix: str) -> Optional[str]: def dotnet_version(self) -> Optional[str]: return self.raw.get("DOTNET_VERSION") + @property + def node_version(self) -> Optional[str]: + return self.raw.get("NODE_VERSION") + @property def cmake_version(self) -> Optional[str]: return self.raw.get("CMAKE_VERSION") @@ -950,7 +954,7 @@ def hydrate_platform_from_env(plat: Platform) -> None: # --------------------------------------------------------------------------- -LANGS_ALL = ("go", "cpp", "csharp") +LANGS_ALL = ("go", "cpp", "csharp", "ts") def _which(name: str) -> Optional[str]: @@ -1028,8 +1032,8 @@ def _setup_macos(langs: list[str], ctx: "Context") -> int: # Brew packages: only the version-tolerant pieces (cmake, ninja, build # essentials). Go and protobuf are pinned via tarball / vcpkg below; - # buf is pinned via direct download. dotnet@N is pinnable via brew's - # versioned formulae. + # buf is pinned via direct download. dotnet@N and node@N are pinnable + # via brew's versioned formulae. pkgs: list[str] = [] if "cpp" in langs: pkgs.extend(["cmake", "ninja"]) @@ -1037,12 +1041,15 @@ def _setup_macos(langs: list[str], ctx: "Context") -> int: # `dotnet@8` (cask) covers .NET 8.x. Use the major. major = (ctx.versions.dotnet_version or "8.0").split(".")[0] pkgs.append(f"dotnet@{major}") + if "ts" in langs: + pkgs.append(f"node@{ctx.versions.node_version or '20'}") if pkgs: ctx.runner.run(["brew", "update"], check=False) ctx.runner.run(["brew", "install", *pkgs], check=False) - # Pinned Go via official tarball (matches devcontainer). - if "go" in langs or "cpp" in langs or "csharp" in langs: + # Pinned Go via official tarball (matches devcontainer). Required for any + # language: every buf-generate invokes the Go protoc plugins via `go run`. + if "go" in langs or "cpp" in langs or "csharp" in langs or "ts" in langs: _ensure_go_tarball(ctx, "darwin") # Pinned buf via GitHub release. @@ -1094,8 +1101,9 @@ def _setup_linux(langs: list[str], ctx: "Context") -> int: [*sudo_prefix, "dnf", "install", "-y", *base_pkgs], check=False ) - # Pinned Go via official tarball. - if "go" in langs or "cpp" in langs or "csharp" in langs: + # Pinned Go via official tarball. Required for any language: every + # buf-generate invokes the Go protoc plugins via `go run`. + if "go" in langs or "cpp" in langs or "csharp" in langs or "ts" in langs: _ensure_go_tarball(ctx, "linux") # Pinned buf via GitHub release. @@ -1103,6 +1111,8 @@ def _setup_linux(langs: list[str], ctx: "Context") -> int: if "csharp" in langs: _ensure_dotnet_linux(ctx) + if "ts" in langs: + _ensure_node_linux(ctx) # Pinned protobuf via vcpkg (matches devcontainer + Windows). if "cpp" in langs: @@ -1210,6 +1220,46 @@ def _ensure_go_tarball(ctx: "Context", os_label: str) -> None: print(f" export PATH={bin_dir}:$PATH") +def _ensure_node_linux(ctx: "Context") -> None: + """Install Node.js at NODE_VERSION (major) via NodeSource, mirroring the + devcontainer Dockerfile. A `node` already on PATH is accepted as-is — we + don't auto-replace an existing install. + + NodeSource ships distro-specific setup scripts (`deb`/`rpm`) that register + the apt/dnf repo and refresh the package lists; we then install the + `nodejs` package. Both the setup script and the install need root, so they + go through sudo when we're not already uid 0. + """ + if _which("node") is not None: + return + ver = ctx.versions.node_version + if not ver: + print("[warn] NODE_VERSION not set; skipping Node install.", file=sys.stderr) + return + # NodeSource setup scripts are keyed by major only: `setup_.x`. + major = ver.split(".")[0] + apt = _which("apt-get") is not None + dnf = _which("dnf") is not None + if not (apt or dnf): + print( + "[warn] Neither apt-get nor dnf found; install Node manually.", + file=sys.stderr, + ) + return + sudo_prefix = ["sudo"] if os.geteuid() != 0 else [] + repo = "deb" if apt else "rpm" + url = f"https://{repo}.nodesource.com/setup_{major}.x" + script = Path.home() / ".local" / "bin" / "nodesource-setup.sh" + ctx.runner.mkdirp(script.parent) + print(f"[info] Registering NodeSource repo for Node {major}.x") + if not ctx.runner.dry_run: + urllib.request.urlretrieve(url, str(script)) + script.chmod(0o755) + ctx.runner.run([*sudo_prefix, "bash", str(script)], check=False) + pkg_mgr = "apt-get" if apt else "dnf" + ctx.runner.run([*sudo_prefix, pkg_mgr, "install", "-y", "nodejs"], check=False) + + def _setup_windows(langs: list[str], ctx: "Context", skip_vcpkg: bool) -> int: """Windows host setup.""" cache = load_loader_env() @@ -1356,6 +1406,15 @@ def _setup_windows(langs: list[str], ctx: "Context", skip_vcpkg: bool) -> int: check=False, ) + # Step 8: Node.js (only when ts tests are requested). winget's LTS package + # tracks the current LTS line; CI pins an exact version via setup-node, so + # this native-dev install is best-effort (no hard version pin on Windows). + if "ts" in langs and _which("node") is None: + ctx.runner.run( + ["winget", "install", "--id", "OpenJS.NodeJS.LTS", "-e"], + check=False, + ) + save_loader_env(cache, ctx.runner) print("[info] Windows toolchain ready.") print( @@ -1501,6 +1560,8 @@ def _build_or_test(args, ctx: "Context", run_tests: bool) -> int: return _cpp_build_or_test(args, ctx, run_tests) if lang == "csharp": return _csharp_build_or_test(args, ctx, run_tests) + if lang == "ts": + return _ts_build_or_test(args, ctx, run_tests) print(f"[error] unknown --lang {lang}", file=sys.stderr) return 2 @@ -1788,6 +1849,51 @@ def _csharp_build_or_test(args, ctx: "Context", run_tests: bool) -> int: return 0 +# ----- TypeScript ----- + + +def _npm_cmd(ctx: "Context") -> str: + """npm executable name. On Windows npm is a `.cmd` batch shim, which + subprocess (shell=False) only resolves with the explicit extension.""" + return "npm.cmd" if ctx.platform.is_windows else "npm" + + +def _ts_build_or_test(args, ctx: "Context", run_tests: bool) -> int: + """Build/test the TypeScript loader harness (test/ts-tableau-loader). + + Flow mirrors the package.json scripts: + generate -> `buf generate ..` (Go protoc plugins via `go run`) + install -> `npm ci` (lockfile present) or `npm install` + build -> `npm run check` (tsc --noEmit type check) + test -> `npm run check` then `npm run smoke` (tsx tests/smoke.ts) + + Unlike go/cpp/csharp there is no emitted artifact: ESM TS is consumed + directly by tsx (in-memory transpile), so "build" is just a type check. + """ + cwd = _lang_dir(ctx.repo_root, "ts") + npm = _npm_cmd(ctx) + + if not getattr(args, "no_generate", False): + _buf_generate(ctx, "ts") + + # Install deps: prefer the reproducible `npm ci` when a lockfile exists. + install_cmd = ( + [npm, "ci"] if (cwd / "package-lock.json").is_file() else [npm, "install"] + ) + ctx.runner.run(install_cmd, cwd=cwd) + + # Type-check (the closest equivalent to "build" for a noEmit TS project). + ctx.runner.run([npm, "run", "check"], cwd=cwd) + + if not run_tests: + return 0 + + # Smoke test via tsx. The smoke harness runs the full suite (no per-test + # filter), so -k is accepted but ignored for ts. + ctx.runner.run([npm, "run", "smoke"], cwd=cwd) + return 0 + + # ----- clean / env ----- @@ -1809,6 +1915,10 @@ def cmd_clean(args, ctx: "Context") -> int: ctx.runner.rmtree(cwd / "protoconf") elif lang == "go": ctx.runner.rmtree(cwd / "protoconf") + elif lang == "ts": + ctx.runner.rmtree(cwd / "protoconf") + ctx.runner.rmtree(cwd / "tableau") + ctx.runner.rmtree(cwd / "node_modules") return 0 @@ -1836,6 +1946,8 @@ def cmd_env(args, ctx: "Context") -> int: "cmake": _which("cmake"), "ninja": _which("ninja"), "dotnet": _which("dotnet"), + "node": _which("node"), + "npm": _which("npm"), }, "versions_env": ctx.versions.raw, } diff --git a/test/ts-tableau-loader/buf.gen.yaml b/test/ts-tableau-loader/buf.gen.yaml new file mode 100644 index 0000000..80b53a4 --- /dev/null +++ b/test/ts-tableau-loader/buf.gen.yaml @@ -0,0 +1,33 @@ +version: v2 +# Mirror the per-language buf.gen.yaml pattern: a "base" plugin emits the +# protobuf message types, then protoc-gen-ts-tableau-loader wraps them into +# Messager/Hub code (exactly like protocolbuffers/go + +# protoc-gen-go-tableau-loader on the Go side). +plugins: + # protobuf-es generator (BSR remote plugin). Keep this version in lockstep + # with the @bufbuild/protobuf runtime in package.json: the generated *_pb.ts + # import from @bufbuild/protobuf at runtime, so generator and runtime must + # match. + - remote: buf.build/bufbuild/es:v2.2.3 + out: protoconf + # The proto files use tableau.* extension options, so the generated + # message modules import the tableau/protobuf descriptors; pull those in + # here (mirrors include_imports in the cpp/csharp buf.gen.yaml). + include_imports: true + opt: + - target=ts + # Emit explicit ".js" extensions on relative imports so the output is + # valid under TS's nodenext/node16 ESM resolution (and plain Node ESM). + - import_extension=js + # tableau-ts loader generator. Emits *_tableau.ts wrappers + hub.ts + the + # runtime library (util/load/messager) into "tableau". The base protobuf-es + # messages live in the sibling "protoconf" dir, so loader code imports them + # via "../protoconf/_pb.js" (configured by pb_path below). + - local: ["go", "run", "../../cmd/protoc-gen-ts-tableau-loader"] + out: tableau + opt: + - paths=source_relative + # Relative path from this loader output dir to the protobuf-es base + # output dir ("protoconf"), used to build the base message imports. + - pb_path=../protoconf + strategy: all diff --git a/test/ts-tableau-loader/package-lock.json b/test/ts-tableau-loader/package-lock.json new file mode 100644 index 0000000..3c43c50 --- /dev/null +++ b/test/ts-tableau-loader/package-lock.json @@ -0,0 +1,575 @@ +{ + "name": "tableau-ts-loader-test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tableau-ts-loader-test", + "version": "1.0.0", + "dependencies": { + "@bufbuild/protobuf": "^2.2.3" + }, + "devDependencies": { + "@types/node": "^20.19.43", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/ts-tableau-loader/package.json b/test/ts-tableau-loader/package.json new file mode 100644 index 0000000..71fad11 --- /dev/null +++ b/test/ts-tableau-loader/package.json @@ -0,0 +1,20 @@ +{ + "name": "tableau-ts-loader-test", + "private": true, + "type": "module", + "version": "1.0.0", + "description": "Test harness for protoc-gen-ts-tableau-loader generated loaders.", + "scripts": { + "generate": "buf generate ..", + "check": "tsc --noEmit", + "smoke": "tsx tests/smoke.ts" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.3" + }, + "devDependencies": { + "@types/node": "^20.19.43", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/test/ts-tableau-loader/tableau/barrel/base.pc.ts b/test/ts-tableau-loader/tableau/barrel/base.pc.ts new file mode 100644 index 0000000..f7f91b2 --- /dev/null +++ b/test/ts-tableau-loader/tableau/barrel/base.pc.ts @@ -0,0 +1,12 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +// Barrel for proto package "base": re-exports every protobuf-es +// generated module of this package, so loaders import the whole package +// under one namespace alias (import * as base), matching the +// package-qualified naming used by loaders of other languages. + +export * from "../../protoconf/base/base_pb.js"; diff --git a/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts b/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts new file mode 100644 index 0000000..de1aaff --- /dev/null +++ b/test/ts-tableau-loader/tableau/barrel/protoconf.pc.ts @@ -0,0 +1,18 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +// Barrel for proto package "protoconf": re-exports every protobuf-es +// generated module of this package, so loaders import the whole package +// under one namespace alias (import * as protoconf), matching the +// package-qualified naming used by loaders of other languages. + +export * from "../../protoconf/common_conf_pb.js"; +export * from "../../protoconf/hero_conf_pb.js"; +export * from "../../protoconf/index_conf_pb.js"; +export * from "../../protoconf/item_conf_pb.js"; +export * from "../../protoconf/patch_conf_pb.js"; +export * from "../../protoconf/test_conf_pb.js"; +export * from "../../protoconf/union_conf_pb.js"; diff --git a/test/ts-tableau-loader/tableau/hero_conf.pc.ts b/test/ts-tableau-loader/tableau/hero_conf.pc.ts new file mode 100644 index 0000000..4e8a5c7 --- /dev/null +++ b/test/ts-tableau-loader/tableau/hero_conf.pc.ts @@ -0,0 +1,101 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: hero_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; +import * as base from "./barrel/base.pc.js"; + +/** + * HeroConf is a wrapper around protobuf message protoconf.HeroConf. + */ +export class HeroConf extends Messager { + private data_: protoconf.HeroConf = create(protoconf.HeroConfSchema); + + /** name returns the HeroConf's message name. */ + name(): string { + return protoconf.HeroConfSchema.name; + } + + /** load loads HeroConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.HeroConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load HeroConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the HeroConf's inner message data. */ + data(): protoconf.HeroConf { + return this.data_; + } + + /** message returns the HeroConf's inner message data. */ + override message(): protoconf.HeroConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): protoconf.HeroConf_Hero | undefined { + return this.data_.heroMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, title: string): protoconf.HeroConf_Hero_Attr | undefined { + return this.get1(name)?.attrMap[title]; + } +} + +/** + * HeroBaseConf is a wrapper around protobuf message protoconf.HeroBaseConf. + */ +export class HeroBaseConf extends Messager { + private data_: protoconf.HeroBaseConf = create(protoconf.HeroBaseConfSchema); + + /** name returns the HeroBaseConf's message name. */ + name(): string { + return protoconf.HeroBaseConfSchema.name; + } + + /** load loads HeroBaseConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.HeroBaseConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load HeroBaseConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the HeroBaseConf's inner message data. */ + data(): protoconf.HeroBaseConf { + return this.data_; + } + + /** message returns the HeroBaseConf's inner message data. */ + override message(): protoconf.HeroBaseConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): base.Hero | undefined { + return this.data_.heroMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, id: string): base.Item | undefined { + return this.get1(name)?.itemMap[id]; + } +} diff --git a/test/ts-tableau-loader/tableau/hub.pc.ts b/test/ts-tableau-loader/tableau/hub.pc.ts new file mode 100644 index 0000000..e6520f2 --- /dev/null +++ b/test/ts-tableau-loader/tableau/hub.pc.ts @@ -0,0 +1,216 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { Messager, type MessagerCtor } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { type Options, parseMessagerOptionsByName } from "./load.pc.js"; +import { HeroConf, HeroBaseConf } from "./hero_conf.pc.js"; +import { FruitConf, Fruit6Conf, Fruit2Conf, Fruit3Conf, Fruit4Conf, Fruit5Conf } from "./index_conf.pc.js"; +import { ItemConf } from "./item_conf.pc.js"; +import { PatchReplaceConf, PatchMergeConf, RecursivePatchConf } from "./patch_conf.pc.js"; +import { ActivityConf, ChapterConf, ThemeConf, TaskConf, StrcaseConf } from "./test_conf.pc.js"; + +/** + * HubOptions is the options for Hub. + */ +export interface HubOptions { + /** + * filter can only filter in certain specific messagers based on the + * condition that you provide. + */ + filter?: (name: string) => boolean; +} + +/** + * Registry manages the registration of all messager generators. + */ +export class Registry { + static readonly registrar: Map = new Map(); + + /** register registers a messager constructor. */ + static register(ctor: MessagerCtor): void { + Registry.registrar.set(new ctor().name(), ctor); + } + + /** init registers all generated messagers. */ + static init(): void { + Registry.register(HeroConf); + Registry.register(HeroBaseConf); + Registry.register(FruitConf); + Registry.register(Fruit6Conf); + Registry.register(Fruit2Conf); + Registry.register(Fruit3Conf); + Registry.register(Fruit4Conf); + Registry.register(Fruit5Conf); + Registry.register(ItemConf); + Registry.register(PatchReplaceConf); + Registry.register(PatchMergeConf); + Registry.register(RecursivePatchConf); + Registry.register(ActivityConf); + Registry.register(ChapterConf); + Registry.register(ThemeConf); + Registry.register(TaskConf); + Registry.register(StrcaseConf); + } +} + +Registry.init(); + +/** + * Hub is the messager manager. It manages loading, accessing, and storing + * all configuration messagers. + */ +export class Hub { + private messagerMap: Map = new Map(); + private lastLoadedTime: Date | undefined; + private readonly options?: HubOptions; + + constructor(options?: HubOptions) { + this.options = options; + } + + /** + * load fills messages from files in the specified directory and format. + * Throws an Error (with the underlying cause chained) on failure. + */ + load(dir: string, fmt: Format, options?: Options): void { + const messagerMap = this.newMessagerMap(); + const opts = options ?? {}; + for (const [name, messager] of messagerMap) { + try { + messager.load(dir, fmt, parseMessagerOptionsByName(opts, name)); + } catch (e) { + throw new Error(`load ${name} failed`, { cause: e }); + } + } + const tmpHub = new Hub(); + tmpHub.setMessagerMap(messagerMap); + for (const [name, messager] of messagerMap) { + try { + messager.processAfterLoadAll(tmpHub); + } catch (e) { + throw new Error(`hub call processAfterLoadAll failed, messager: ${name}`, { cause: e }); + } + } + this.setMessagerMap(messagerMap); + } + + /** getMessagerMap returns the current messager map. */ + getMessagerMap(): Map { + return this.messagerMap; + } + + /** setMessagerMap sets the messager map. */ + setMessagerMap(map: Map): void { + this.messagerMap = map; + this.lastLoadedTime = new Date(); + } + + /** getMessager returns the messager by name. */ + getMessager(name: string): Messager | undefined { + return this.messagerMap.get(name); + } + + /** getLastLoadedTime returns the time when hub's messager map was last set. */ + getLastLoadedTime(): Date | undefined { + return this.lastLoadedTime; + } + + private newMessagerMap(): Map { + const messagerMap = new Map(); + for (const [name, ctor] of Registry.registrar) { + if (!this.options?.filter || this.options.filter(name)) { + messagerMap.set(name, new ctor()); + } + } + return messagerMap; + } + + /** getHeroConf returns the HeroConf messager. */ + getHeroConf(): HeroConf | undefined { + return this.messagerMap.get("HeroConf") as HeroConf | undefined; + } + + /** getHeroBaseConf returns the HeroBaseConf messager. */ + getHeroBaseConf(): HeroBaseConf | undefined { + return this.messagerMap.get("HeroBaseConf") as HeroBaseConf | undefined; + } + + /** getFruitConf returns the FruitConf messager. */ + getFruitConf(): FruitConf | undefined { + return this.messagerMap.get("FruitConf") as FruitConf | undefined; + } + + /** getFruit6Conf returns the Fruit6Conf messager. */ + getFruit6Conf(): Fruit6Conf | undefined { + return this.messagerMap.get("Fruit6Conf") as Fruit6Conf | undefined; + } + + /** getFruit2Conf returns the Fruit2Conf messager. */ + getFruit2Conf(): Fruit2Conf | undefined { + return this.messagerMap.get("Fruit2Conf") as Fruit2Conf | undefined; + } + + /** getFruit3Conf returns the Fruit3Conf messager. */ + getFruit3Conf(): Fruit3Conf | undefined { + return this.messagerMap.get("Fruit3Conf") as Fruit3Conf | undefined; + } + + /** getFruit4Conf returns the Fruit4Conf messager. */ + getFruit4Conf(): Fruit4Conf | undefined { + return this.messagerMap.get("Fruit4Conf") as Fruit4Conf | undefined; + } + + /** getFruit5Conf returns the Fruit5Conf messager. */ + getFruit5Conf(): Fruit5Conf | undefined { + return this.messagerMap.get("Fruit5Conf") as Fruit5Conf | undefined; + } + + /** getItemConf returns the ItemConf messager. */ + getItemConf(): ItemConf | undefined { + return this.messagerMap.get("ItemConf") as ItemConf | undefined; + } + + /** getPatchReplaceConf returns the PatchReplaceConf messager. */ + getPatchReplaceConf(): PatchReplaceConf | undefined { + return this.messagerMap.get("PatchReplaceConf") as PatchReplaceConf | undefined; + } + + /** getPatchMergeConf returns the PatchMergeConf messager. */ + getPatchMergeConf(): PatchMergeConf | undefined { + return this.messagerMap.get("PatchMergeConf") as PatchMergeConf | undefined; + } + + /** getRecursivePatchConf returns the RecursivePatchConf messager. */ + getRecursivePatchConf(): RecursivePatchConf | undefined { + return this.messagerMap.get("RecursivePatchConf") as RecursivePatchConf | undefined; + } + + /** getActivityConf returns the ActivityConf messager. */ + getActivityConf(): ActivityConf | undefined { + return this.messagerMap.get("ActivityConf") as ActivityConf | undefined; + } + + /** getChapterConf returns the ChapterConf messager. */ + getChapterConf(): ChapterConf | undefined { + return this.messagerMap.get("ChapterConf") as ChapterConf | undefined; + } + + /** getThemeConf returns the ThemeConf messager. */ + getThemeConf(): ThemeConf | undefined { + return this.messagerMap.get("ThemeConf") as ThemeConf | undefined; + } + + /** getTaskConf returns the TaskConf messager. */ + getTaskConf(): TaskConf | undefined { + return this.messagerMap.get("TaskConf") as TaskConf | undefined; + } + + /** getStrcaseConf returns the StrcaseConf messager. */ + getStrcaseConf(): StrcaseConf | undefined { + return this.messagerMap.get("StrcaseConf") as StrcaseConf | undefined; + } +} diff --git a/test/ts-tableau-loader/tableau/index_conf.pc.ts b/test/ts-tableau-loader/tableau/index_conf.pc.ts new file mode 100644 index 0000000..952c5fa --- /dev/null +++ b/test/ts-tableau-loader/tableau/index_conf.pc.ts @@ -0,0 +1,266 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: index_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * FruitConf is a wrapper around protobuf message protoconf.FruitConf. + */ +export class FruitConf extends Messager { + private data_: protoconf.FruitConf = create(protoconf.FruitConfSchema); + + /** name returns the FruitConf's message name. */ + name(): string { + return protoconf.FruitConfSchema.name; + } + + /** load loads FruitConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.FruitConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load FruitConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the FruitConf's inner message data. */ + data(): protoconf.FruitConf { + return this.data_; + } + + /** message returns the FruitConf's inner message data. */ + override message(): protoconf.FruitConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.FruitConf_Fruit | undefined { + return this.data_.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.get1(fruitType)?.itemMap[id]; + } +} + +/** + * Fruit6Conf is a wrapper around protobuf message protoconf.Fruit6Conf. + */ +export class Fruit6Conf extends Messager { + private data_: protoconf.Fruit6Conf = create(protoconf.Fruit6ConfSchema); + + /** name returns the Fruit6Conf's message name. */ + name(): string { + return protoconf.Fruit6ConfSchema.name; + } + + /** load loads Fruit6Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.Fruit6ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit6Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit6Conf's inner message data. */ + data(): protoconf.Fruit6Conf { + return this.data_; + } + + /** message returns the Fruit6Conf's inner message data. */ + override message(): protoconf.Fruit6Conf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit6Conf_Fruit | undefined { + return this.data_.fruitMap[fruitType]; + } +} + +/** + * Fruit2Conf is a wrapper around protobuf message protoconf.Fruit2Conf. + */ +export class Fruit2Conf extends Messager { + private data_: protoconf.Fruit2Conf = create(protoconf.Fruit2ConfSchema); + + /** name returns the Fruit2Conf's message name. */ + name(): string { + return protoconf.Fruit2ConfSchema.name; + } + + /** load loads Fruit2Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.Fruit2ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit2Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit2Conf's inner message data. */ + data(): protoconf.Fruit2Conf { + return this.data_; + } + + /** message returns the Fruit2Conf's inner message data. */ + override message(): protoconf.Fruit2Conf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit2Conf_Fruit | undefined { + return this.data_.fruitMap[fruitType]; + } +} + +/** + * Fruit3Conf is a wrapper around protobuf message protoconf.Fruit3Conf. + */ +export class Fruit3Conf extends Messager { + private data_: protoconf.Fruit3Conf = create(protoconf.Fruit3ConfSchema); + + /** name returns the Fruit3Conf's message name. */ + name(): string { + return protoconf.Fruit3ConfSchema.name; + } + + /** load loads Fruit3Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.Fruit3ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit3Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit3Conf's inner message data. */ + data(): protoconf.Fruit3Conf { + return this.data_; + } + + /** message returns the Fruit3Conf's inner message data. */ + override message(): protoconf.Fruit3Conf { + return this.data_; + } +} + +/** + * Fruit4Conf is a wrapper around protobuf message protoconf.Fruit4Conf. + */ +export class Fruit4Conf extends Messager { + private data_: protoconf.Fruit4Conf = create(protoconf.Fruit4ConfSchema); + + /** name returns the Fruit4Conf's message name. */ + name(): string { + return protoconf.Fruit4ConfSchema.name; + } + + /** load loads Fruit4Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.Fruit4ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit4Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit4Conf's inner message data. */ + data(): protoconf.Fruit4Conf { + return this.data_; + } + + /** message returns the Fruit4Conf's inner message data. */ + override message(): protoconf.Fruit4Conf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit4Conf_Fruit | undefined { + return this.data_.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.get1(fruitType)?.countryMap[id]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(fruitType: number, id: number, id3: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.get2(fruitType, id)?.itemMap[id3]; + } +} + +/** + * Fruit5Conf is a wrapper around protobuf message protoconf.Fruit5Conf. + */ +export class Fruit5Conf extends Messager { + private data_: protoconf.Fruit5Conf = create(protoconf.Fruit5ConfSchema); + + /** name returns the Fruit5Conf's message name. */ + name(): string { + return protoconf.Fruit5ConfSchema.name; + } + + /** load loads Fruit5Conf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.Fruit5ConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load Fruit5Conf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the Fruit5Conf's inner message data. */ + data(): protoconf.Fruit5Conf { + return this.data_; + } + + /** message returns the Fruit5Conf's inner message data. */ + override message(): protoconf.Fruit5Conf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(fruitType: number): protoconf.Fruit5Conf_Fruit | undefined { + return this.data_.fruitMap[fruitType]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(fruitType: number, id: number): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.get1(fruitType)?.countryMap[id]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(fruitType: number, id: number, id3: number): protoconf.Fruit5Conf_Fruit_Country_Item | undefined { + return this.get2(fruitType, id)?.itemMap[id3]; + } +} diff --git a/test/ts-tableau-loader/tableau/item_conf.pc.ts b/test/ts-tableau-loader/tableau/item_conf.pc.ts new file mode 100644 index 0000000..87cdf1b --- /dev/null +++ b/test/ts-tableau-loader/tableau/item_conf.pc.ts @@ -0,0 +1,51 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: item_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * ItemConf is a wrapper around protobuf message protoconf.ItemConf. + */ +export class ItemConf extends Messager { + private data_: protoconf.ItemConf = create(protoconf.ItemConfSchema); + + /** name returns the ItemConf's message name. */ + name(): string { + return protoconf.ItemConfSchema.name; + } + + /** load loads ItemConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.ItemConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ItemConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ItemConf's inner message data. */ + data(): protoconf.ItemConf { + return this.data_; + } + + /** message returns the ItemConf's inner message data. */ + override message(): protoconf.ItemConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: number): protoconf.ItemConf_Item | undefined { + return this.data_.itemMap[id]; + } +} diff --git a/test/ts-tableau-loader/tableau/load.pc.ts b/test/ts-tableau-loader/tableau/load.pc.ts new file mode 100644 index 0000000..7f3f54d --- /dev/null +++ b/test/ts-tableau-loader/tableau/load.pc.ts @@ -0,0 +1,274 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + create, + fromBinary, + fromJson, + type DescMessage, + type JsonValue, + type Message, + type MessageShape, +} from "@bufbuild/protobuf"; +import * as tableaupb from "../protoconf/tableau/protobuf/tableau_pb.js"; +import { + Format, + format2Ext, + getFormat, + getSheetPatch, + patchMessage, +} from "./util.pc.js"; + +export { Format } from "./util.pc.js"; + +/** + * ReadFunc reads a config file and returns its content. + */ +export type ReadFunc = (path: string) => Uint8Array; + +/** + * LoadFunc loads a messager's content based on the given descriptor, path, + * format, and options. + */ +export type LoadFunc = ( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +) => Message; + +/** + * LoadMode controls patch loading behavior. + */ +export enum LoadMode { + /** Load all related files (main + patches). Default. */ + ALL = "all", + /** Only load the main file. */ + ONLY_MAIN = "only_main", + /** Only load the patch files. */ + ONLY_PATCH = "only_patch", +} + +/** + * BaseOptions is the common options for both global-level and messager-level options. + */ +export interface BaseOptions { + /** Whether to ignore unknown JSON fields during parsing. */ + ignoreUnknownFields?: boolean; + /** Specify the directory paths for config patching. */ + patchDirs?: string[]; + /** Specify the loading mode for config patching. Default is LoadMode.ALL. */ + mode?: LoadMode; + /** Custom read function to read a config file's content. Default is readFileSync. */ + readFunc?: ReadFunc; + /** Custom load function to load a messager's content. Default is loadMessager. */ + loadFunc?: LoadFunc; +} + +/** + * MessagerOptions defines the options for loading a single messager. + */ +export interface MessagerOptions extends BaseOptions { + /** + * Path maps the messager to a corresponding config file path. If specified, + * then the main messager will be parsed from the file directly, other than + * the specified load dir. + */ + path?: string; + /** + * PatchPaths maps the messager to one or multiple corresponding patch file + * paths. If specified, then the main messager will be patched. + */ + patchPaths?: string[]; +} + +/** + * Options is the global-level options, which contains both global-level and + * messager-level options. + */ +export interface Options extends BaseOptions { + /** + * messagerOptions maps each messager name to a MessagerOptions. If specified, + * then the messager will be parsed with the given options directly. + */ + messagerOptions?: { [name: string]: MessagerOptions }; +} + +/** + * parseMessagerOptionsByName parses messager options with both global-level and + * messager-level options taken into consideration. + */ +export function parseMessagerOptionsByName(options: Options, name: string): MessagerOptions { + const mopts: MessagerOptions = options.messagerOptions?.[name] + ? { ...options.messagerOptions[name] } + : {}; + mopts.ignoreUnknownFields ??= options.ignoreUnknownFields; + mopts.patchDirs ??= options.patchDirs; + mopts.mode ??= options.mode; + mopts.readFunc ??= options.readFunc; + mopts.loadFunc ??= options.loadFunc; + return mopts; +} + +function defaultRead(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +/** + * loadMessager loads a protobuf message from the specified file path and format. + */ +export function loadMessager( + desc: DescMessage, + path: string, + fmt: Format, + options?: MessagerOptions, +): Message { + const readFunc = options?.readFunc ?? defaultRead; + let content: Uint8Array; + try { + content = readFunc(path); + } catch (e) { + throw new Error(`failed to read ${path}`, { cause: e }); + } + return unmarshal(content, desc, fmt, options); +} + +/** + * loadMessagerInDir loads a protobuf message from the specified directory and + * format. It resolves the file path based on the message descriptor name. + */ +export function loadMessagerInDir( + desc: Desc, + dir: string, + fmt: Format, + options?: MessagerOptions, +): MessageShape { + let path = ""; + if (options?.path) { + path = options.path; + fmt = getFormat(path); + } + if (path === "") { + path = join(dir, desc.name + format2Ext(fmt)); + } + const sheetPatch = getSheetPatch(desc); + let msg: Message; + if (sheetPatch !== tableaupb.Patch.NONE) { + msg = loadMessagerWithPatch(desc, path, fmt, sheetPatch, options); + } else { + const loadFunc = options?.loadFunc ?? loadMessager; + msg = loadFunc(desc, path, fmt, options); + } + return msg as MessageShape; +} + +/** + * loadMessagerWithPatch loads a protobuf message with patch support. + */ +export function loadMessagerWithPatch( + desc: DescMessage, + path: string, + fmt: Format, + patch: tableaupb.Patch, + options?: MessagerOptions, +): Message { + const mode = options?.mode ?? LoadMode.ALL; + const loadFunc = options?.loadFunc ?? loadMessager; + if (mode === LoadMode.ONLY_MAIN) { + // Ignore patch files when LoadMode.ONLY_MAIN specified. + return loadFunc(desc, path, fmt, options); + } + + let patchPaths: string[] = []; + const explicitPaths = options?.patchPaths && options.patchPaths.length > 0; + if (explicitPaths) { + // PatchPaths takes precedence over PatchDirs. + patchPaths = [...options!.patchPaths!]; + } else if (options?.patchDirs) { + const filename = desc.name + format2Ext(fmt); + for (const patchDir of options.patchDirs) { + patchPaths.push(join(patchDir, filename)); + } + } + + // Filter out non-existing patch files when relying on PatchDirs. + let existedPatchPaths: string[]; + if (explicitPaths) { + // Explicit paths are kept as-is; loadFunc surfaces errors if missing. + existedPatchPaths = patchPaths; + } else { + existedPatchPaths = patchPaths.filter((p) => existsSync(p)); + } + + if (existedPatchPaths.length === 0) { + if (mode === LoadMode.ONLY_PATCH) { + // Return empty message when LoadMode.ONLY_PATCH specified but no valid + // patch file provided. + return create(desc); + } + // No valid patch path provided, then just load from the "main" file. + return loadFunc(desc, path, fmt, options); + } + + switch (patch) { + case tableaupb.Patch.REPLACE: { + // Just use the last "patch" file. + const patchPath = existedPatchPaths[existedPatchPaths.length - 1]!; + return loadFunc(desc, patchPath, getFormat(patchPath), options); + } + case tableaupb.Patch.MERGE: { + let msg: Message; + if (mode !== LoadMode.ONLY_PATCH) { + // Load msg from the "main" file. + msg = loadFunc(desc, path, fmt, options); + } else { + msg = create(desc); + } + for (const patchPath of existedPatchPaths) { + const patchMsg = loadFunc(desc, patchPath, getFormat(patchPath), options); + patchMessage(msg, patchMsg, desc); + } + return msg; + } + default: + throw new Error(`unknown patch type: ${patch}`); + } +} + +/** + * unmarshal parses the given byte content into a protobuf message based on the + * specified format. + */ +export function unmarshal( + content: Uint8Array, + desc: DescMessage, + fmt: Format, + options?: MessagerOptions, +): Message { + switch (fmt) { + case Format.JSON: + try { + const json = JSON.parse(new TextDecoder().decode(content)) as JsonValue; + return fromJson(desc, json, { + ignoreUnknownFields: options?.ignoreUnknownFields ?? false, + }); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.json`, { cause: e }); + } + case Format.BIN: + try { + return fromBinary(desc, content); + } catch (e) { + throw new Error(`failed to parse ${desc.name}.binpb`, { cause: e }); + } + default: + throw new Error(`unknown format: ${fmt}`); + } +} + diff --git a/test/ts-tableau-loader/tableau/messager.pc.ts b/test/ts-tableau-loader/tableau/messager.pc.ts new file mode 100644 index 0000000..a706e79 --- /dev/null +++ b/test/ts-tableau-loader/tableau/messager.pc.ts @@ -0,0 +1,59 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { type Message } from "@bufbuild/protobuf"; +import { Format } from "./util.pc.js"; +import { type MessagerOptions } from "./load.pc.js"; +// Type-only import to describe the hub passed to processAfterLoadAll. Importing +// only the type avoids a runtime cycle with the generated hub module. +import type { Hub } from "./hub.pc.js"; + +/** + * Stats contains statistics info about loading. + */ +export interface Stats { + /** Total load time consuming, in milliseconds. */ + durationMs: number; +} + +/** + * Messager is the base class for all generated configuration messagers. + * It is designed for three goals: + * 1. Easy use: simple yet powerful accessors. + * 2. Elegant API: concise and clean functions. + * 3. Extensibility: Map, OrderedMap, Index, OrderedIndex... + */ +export abstract class Messager { + protected loadStats: Stats = { durationMs: 0 }; + + /** getStats returns the loading stats info. */ + getStats(): Stats { + return this.loadStats; + } + + /** name returns the messager's message name. */ + abstract name(): string; + + /** load fills message from file in the specified directory and format. Throws on failure. */ + abstract load(dir: string, fmt: Format, options?: MessagerOptions): void; + + /** message returns the inner protobuf message data. */ + message(): Message | undefined { + return undefined; + } + + /** processAfterLoad is invoked after this messager is loaded. Throws on failure. */ + protected processAfterLoad(): void {} + + /** processAfterLoadAll is invoked after all messagers are loaded. Throws on failure. */ + processAfterLoadAll(_hub: Hub): void {} +} + +/** + * MessagerCtor is the constructor type for a concrete Messager. + */ +export type MessagerCtor = new () => Messager; + diff --git a/test/ts-tableau-loader/tableau/patch_conf.pc.ts b/test/ts-tableau-loader/tableau/patch_conf.pc.ts new file mode 100644 index 0000000..e3ebe33 --- /dev/null +++ b/test/ts-tableau-loader/tableau/patch_conf.pc.ts @@ -0,0 +1,139 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: patch_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * PatchReplaceConf is a wrapper around protobuf message protoconf.PatchReplaceConf. + */ +export class PatchReplaceConf extends Messager { + private data_: protoconf.PatchReplaceConf = create(protoconf.PatchReplaceConfSchema); + + /** name returns the PatchReplaceConf's message name. */ + name(): string { + return protoconf.PatchReplaceConfSchema.name; + } + + /** load loads PatchReplaceConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.PatchReplaceConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load PatchReplaceConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the PatchReplaceConf's inner message data. */ + data(): protoconf.PatchReplaceConf { + return this.data_; + } + + /** message returns the PatchReplaceConf's inner message data. */ + override message(): protoconf.PatchReplaceConf { + return this.data_; + } +} + +/** + * PatchMergeConf is a wrapper around protobuf message protoconf.PatchMergeConf. + */ +export class PatchMergeConf extends Messager { + private data_: protoconf.PatchMergeConf = create(protoconf.PatchMergeConfSchema); + + /** name returns the PatchMergeConf's message name. */ + name(): string { + return protoconf.PatchMergeConfSchema.name; + } + + /** load loads PatchMergeConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.PatchMergeConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load PatchMergeConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the PatchMergeConf's inner message data. */ + data(): protoconf.PatchMergeConf { + return this.data_; + } + + /** message returns the PatchMergeConf's inner message data. */ + override message(): protoconf.PatchMergeConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: number): protoconf.Item | undefined { + return this.data_.itemMap[id]; + } +} + +/** + * RecursivePatchConf is a wrapper around protobuf message protoconf.RecursivePatchConf. + */ +export class RecursivePatchConf extends Messager { + private data_: protoconf.RecursivePatchConf = create(protoconf.RecursivePatchConfSchema); + + /** name returns the RecursivePatchConf's message name. */ + name(): string { + return protoconf.RecursivePatchConfSchema.name; + } + + /** load loads RecursivePatchConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.RecursivePatchConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load RecursivePatchConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the RecursivePatchConf's inner message data. */ + data(): protoconf.RecursivePatchConf { + return this.data_; + } + + /** message returns the RecursivePatchConf's inner message data. */ + override message(): protoconf.RecursivePatchConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(shopId: number): protoconf.RecursivePatchConf_Shop | undefined { + return this.data_.shopMap[shopId]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(shopId: number, goodsId: number): protoconf.RecursivePatchConf_Shop_Goods | undefined { + return this.get1(shopId)?.goodsMap[goodsId]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(shopId: number, goodsId: number, type: number): protoconf.RecursivePatchConf_Shop_Goods_Currency | undefined { + return this.get2(shopId, goodsId)?.currencyMap[type]; + } + + /** get4 finds value in the 4th-level map; returns undefined if not found. */ + get4(shopId: number, goodsId: number, type: number, key4: number): number | undefined { + return this.get3(shopId, goodsId, type)?.valueList[key4]; + } +} diff --git a/test/ts-tableau-loader/tableau/test_conf.pc.ts b/test/ts-tableau-loader/tableau/test_conf.pc.ts new file mode 100644 index 0000000..b5d20ed --- /dev/null +++ b/test/ts-tableau-loader/tableau/test_conf.pc.ts @@ -0,0 +1,227 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +// source: test_conf.proto +/* eslint-disable */ + +import { create } from "@bufbuild/protobuf"; +import { Messager } from "./messager.pc.js"; +import { Format } from "./util.pc.js"; +import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; +import * as protoconf from "./barrel/protoconf.pc.js"; + +/** + * ActivityConf is a wrapper around protobuf message protoconf.ActivityConf. + */ +export class ActivityConf extends Messager { + private data_: protoconf.ActivityConf = create(protoconf.ActivityConfSchema); + + /** name returns the ActivityConf's message name. */ + name(): string { + return protoconf.ActivityConfSchema.name; + } + + /** load loads ActivityConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.ActivityConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ActivityConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ActivityConf's inner message data. */ + data(): protoconf.ActivityConf { + return this.data_; + } + + /** message returns the ActivityConf's inner message data. */ + override message(): protoconf.ActivityConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(activityId: bigint): protoconf.ActivityConf_Activity | undefined { + return this.data_.activityMap[activityId.toString()]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.get1(activityId)?.chapterMap[chapterId]; + } + + /** get3 finds value in the 3rd-level map; returns undefined if not found. */ + get3(activityId: bigint, chapterId: number, sectionId: number): protoconf.Section | undefined { + return this.get2(activityId, chapterId)?.sectionMap[sectionId]; + } + + /** get4 finds value in the 4th-level map; returns undefined if not found. */ + get4(activityId: bigint, chapterId: number, sectionId: number, key4: number): number | undefined { + return this.get3(activityId, chapterId, sectionId)?.sectionRankMap[key4]; + } +} + +/** + * ChapterConf is a wrapper around protobuf message protoconf.ChapterConf. + */ +export class ChapterConf extends Messager { + private data_: protoconf.ChapterConf = create(protoconf.ChapterConfSchema); + + /** name returns the ChapterConf's message name. */ + name(): string { + return protoconf.ChapterConfSchema.name; + } + + /** load loads ChapterConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.ChapterConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ChapterConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ChapterConf's inner message data. */ + data(): protoconf.ChapterConf { + return this.data_; + } + + /** message returns the ChapterConf's inner message data. */ + override message(): protoconf.ChapterConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.ChapterConf_Chapter | undefined { + return this.data_.chapterMap[id.toString()]; + } +} + +/** + * ThemeConf is a wrapper around protobuf message protoconf.ThemeConf. + */ +export class ThemeConf extends Messager { + private data_: protoconf.ThemeConf = create(protoconf.ThemeConfSchema); + + /** name returns the ThemeConf's message name. */ + name(): string { + return protoconf.ThemeConfSchema.name; + } + + /** load loads ThemeConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.ThemeConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load ThemeConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the ThemeConf's inner message data. */ + data(): protoconf.ThemeConf { + return this.data_; + } + + /** message returns the ThemeConf's inner message data. */ + override message(): protoconf.ThemeConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(name: string): protoconf.ThemeConf_Theme | undefined { + return this.data_.themeMap[name]; + } + + /** get2 finds value in the 2nd-level map; returns undefined if not found. */ + get2(name: string, param: string): string | undefined { + return this.get1(name)?.paramMap[param]; + } +} + +/** + * TaskConf is a wrapper around protobuf message protoconf.TaskConf. + */ +export class TaskConf extends Messager { + private data_: protoconf.TaskConf = create(protoconf.TaskConfSchema); + + /** name returns the TaskConf's message name. */ + name(): string { + return protoconf.TaskConfSchema.name; + } + + /** load loads TaskConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.TaskConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load TaskConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the TaskConf's inner message data. */ + data(): protoconf.TaskConf { + return this.data_; + } + + /** message returns the TaskConf's inner message data. */ + override message(): protoconf.TaskConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.TaskConf_Task | undefined { + return this.data_.taskMap[id.toString()]; + } +} + +/** + * StrcaseConf is a wrapper around protobuf message protoconf.StrcaseConf. + */ +export class StrcaseConf extends Messager { + private data_: protoconf.StrcaseConf = create(protoconf.StrcaseConfSchema); + + /** name returns the StrcaseConf's message name. */ + name(): string { + return protoconf.StrcaseConfSchema.name; + } + + /** load loads StrcaseConf's content in the given dir, based on format and messager options. Throws on failure. */ + load(dir: string, fmt: Format, options?: MessagerOptions): void { + const start = Date.now(); + try { + this.data_ = loadMessagerInDir(protoconf.StrcaseConfSchema, dir, fmt, options); + } catch (e) { + throw new Error(`failed to load StrcaseConf`, { cause: e }); + } + this.loadStats.durationMs = Date.now() - start; + this.processAfterLoad(); + } + + /** data returns the StrcaseConf's inner message data. */ + data(): protoconf.StrcaseConf { + return this.data_; + } + + /** message returns the StrcaseConf's inner message data. */ + override message(): protoconf.StrcaseConf { + return this.data_; + } + + /** get1 finds value in the 1st-level map; returns undefined if not found. */ + get1(id: bigint): protoconf.StrcaseConf_Task | undefined { + return this.data_.taskMap[id.toString()]; + } +} diff --git a/test/ts-tableau-loader/tableau/util.pc.ts b/test/ts-tableau-loader/tableau/util.pc.ts new file mode 100644 index 0000000..143b284 --- /dev/null +++ b/test/ts-tableau-loader/tableau/util.pc.ts @@ -0,0 +1,185 @@ +// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT. +// versions: +// - protoc-gen-ts-tableau-loader v0.1.0 +// - protoc (unknown) +/* eslint-disable */ + +import { clone, getExtension, type DescField, type DescMessage, type Message } from "@bufbuild/protobuf"; +import * as tableaupb from "../protoconf/tableau/protobuf/tableau_pb.js"; + +/** + * Format specifies the format of the configuration file. + */ +export enum Format { + UNKNOWN = "unknown", + JSON = "json", + BIN = "bin", +} + +const UNKNOWN_EXT = ".unknown"; +const JSON_EXT = ".json"; +const BIN_EXT = ".binpb"; + +/** + * getFormat returns the Format determined by the file extension of the given path. + */ +export function getFormat(path: string): Format { + if (path.endsWith(JSON_EXT)) return Format.JSON; + if (path.endsWith(BIN_EXT)) return Format.BIN; + return Format.UNKNOWN; +} + +/** + * format2Ext returns the file extension corresponding to the given format. + */ +export function format2Ext(fmt: Format): string { + switch (fmt) { + case Format.JSON: + return JSON_EXT; + case Format.BIN: + return BIN_EXT; + default: + return UNKNOWN_EXT; + } +} + +// AnyMessage is a structural view over a protobuf-es message used by the +// reflection-based patch routines below. +type AnyMessage = Record; + +/** + * getSheetPatch returns the sheet-level patch type for a message descriptor. + */ +export function getSheetPatch(desc: DescMessage): tableaupb.Patch { + const opts = desc.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const ws = getExtension(opts, tableaupb.worksheet); + return ws?.patch ?? tableaupb.Patch.NONE; +} + +function getFieldPatch(fd: DescField): tableaupb.Patch { + const opts = fd.proto.options; + if (!opts) return tableaupb.Patch.NONE; + const fieldOpts = getExtension(opts, tableaupb.field); + return fieldOpts?.prop?.patch ?? tableaupb.Patch.NONE; +} + +function isFieldPopulated(msg: AnyMessage, fd: DescField): boolean { + const v = msg[fd.localName]; + if (v === undefined || v === null) return false; + switch (fd.fieldKind) { + case "list": + return Array.isArray(v) && v.length > 0; + case "map": + return typeof v === "object" && Object.keys(v as object).length > 0; + case "message": + return true; + default: + if (typeof v === "bigint") return v !== 0n; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") return v.length > 0; + if (typeof v === "boolean") return v; + if (v instanceof Uint8Array) return v.length > 0; + return true; + } +} + +function clearField(msg: AnyMessage, fd: DescField): void { + switch (fd.fieldKind) { + case "list": + msg[fd.localName] = []; + break; + case "map": + msg[fd.localName] = {}; + break; + default: + delete msg[fd.localName]; + break; + } +} + +/** + * patchMessage patches src into dst, which must be messages with the same descriptor. + * + * Default mechanism: + * - scalar: populated scalar fields in src are copied to dst. + * - message: populated singular messages in src are merged into dst recursively, + * or replace dst message if PATCH_REPLACE is specified for the field. + * - list: src elements are appended to dst, or replace dst list on PATCH_REPLACE. + * - map: src entries are merged into dst, or replace dst map on PATCH_REPLACE. + */ +export function patchMessage(dst: Message, src: Message, desc: DescMessage): void { + patchMessageInternal(dst as unknown as AnyMessage, src as unknown as AnyMessage, desc); +} + +function patchMessageInternal(dst: AnyMessage, src: AnyMessage, desc: DescMessage): void { + for (const fd of desc.fields) { + if (!isFieldPopulated(src, fd)) continue; + if (getFieldPatch(fd) === tableaupb.Patch.REPLACE) { + clearField(dst, fd); + } + switch (fd.fieldKind) { + case "map": + patchMap(dst, src, fd); + break; + case "list": + patchList(dst, src, fd); + break; + case "message": { + const name = fd.localName; + const srcChild = src[name] as AnyMessage; + const dstChild = dst[name] as AnyMessage | undefined; + if (dstChild === undefined || dstChild === null) { + dst[name] = clone(fd.message, srcChild as never); + } else { + patchMessageInternal(dstChild, srcChild, fd.message); + } + break; + } + default: + dst[fd.localName] = src[fd.localName]; + break; + } + } +} + +function patchList(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcList = src[fd.localName] as unknown[]; + let dstList = dst[fd.localName] as unknown[] | undefined; + if (!Array.isArray(dstList)) { + dstList = []; + dst[fd.localName] = dstList; + } + const isMessage = fd.fieldKind === "list" && fd.listKind === "message"; + for (const item of srcList) { + if (isMessage && fd.message) { + dstList.push(clone(fd.message, item as never)); + } else { + dstList.push(item); + } + } +} + +function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { + const srcMap = src[fd.localName] as Record; + let dstMap = dst[fd.localName] as Record | undefined; + if (typeof dstMap !== "object" || dstMap === null) { + dstMap = {}; + dst[fd.localName] = dstMap; + } + const isMessageValue = fd.fieldKind === "map" && fd.mapKind === "message"; + for (const [k, v] of Object.entries(srcMap)) { + if (isMessageValue && fd.message) { + const existing = dstMap[k] as AnyMessage | undefined; + if (existing !== undefined && existing !== null) { + // NOTE: this MERGES into the existing value (differs from a simple replace). + patchMessageInternal(existing, v as AnyMessage, fd.message); + } else { + dstMap[k] = clone(fd.message, v as never); + } + } else { + dstMap[k] = v; + } + } +} + diff --git a/test/ts-tableau-loader/tests/smoke.ts b/test/ts-tableau-loader/tests/smoke.ts new file mode 100644 index 0000000..d764246 --- /dev/null +++ b/test/ts-tableau-loader/tests/smoke.ts @@ -0,0 +1,171 @@ +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { equals } from "@bufbuild/protobuf"; + +import { Hub } from "../tableau/hub.pc.js"; +import { Format, LoadMode } from "../tableau/load.pc.js"; +import { + PatchMergeConf, + PatchReplaceConf, + RecursivePatchConf, +} from "../tableau/patch_conf.pc.js"; +import { HeroConf } from "../tableau/hero_conf.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const testdata = join(here, "..", "..", "testdata"); +const confDir = join(testdata, "conf"); +const binDir = join(testdata, "bin"); +const patchConf = join(testdata, "patchconf"); +const patchConf2 = join(testdata, "patchconf2"); +const patchResult = join(testdata, "patchresult"); + +let passed = 0; +function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok - ${name}`); +} + +// 1. Hub loads all messagers from a JSON directory. +const hub = new Hub(); +check("hub.load(conf, JSON)", () => { + hub.load(confDir, Format.JSON, { ignoreUnknownFields: true }); +}); + +// 2. ItemConf: first-level int-keyed map getter. +check("ItemConf map getter", () => { + const item = hub.getItemConf(); + assert.ok(item); + assert.equal(item!.name(), "ItemConf"); + assert.equal(item!.get1(1)?.name, "apple"); + assert.equal(item!.get1(0)?.name, "coin1"); + assert.equal(item!.get1(12345), undefined); +}); + +// 3. ActivityConf: 4-level nested map getter with a 64-bit (bigint) outer key. +check("ActivityConf nested map getters", () => { + const activity = hub.getActivityConf(); + assert.ok(activity); + assert.equal(activity!.get1(100001n)?.activityName, "活动1"); + assert.equal(activity!.get2(100001n, 1)?.chapterName, "签到活动章1"); + // sectionRankMap["2001"] === 2 + assert.equal(activity!.get4(100001n, 1, 2, 2001), 2); + assert.equal(activity!.get3(100001n, 1, 99), undefined); +}); + +// 4. ThemeConf: string-keyed map getter. +check("ThemeConf string-keyed map", () => { + const theme = hub.getThemeConf(); + assert.ok(theme); + assert.ok(Object.keys(theme!.data().themeMap).length > 0); +}); + +// 5. Hub typed accessor for an unloaded-by-filter messager returns undefined. +check("Hub filter", () => { + const filtered = new Hub({ filter: (n) => n === "ItemConf" }); + filtered.load(confDir, Format.JSON, { ignoreUnknownFields: true }); + assert.ok(filtered.getItemConf()); + assert.equal(filtered.getActivityConf(), undefined); +}); + +// 6. Binary format load via a single messager. +check("HeroConf BIN load", () => { + const hero = new HeroConf(); + hero.load(binDir, Format.BIN); + assert.equal(hero.name(), "HeroConf"); +}); + +// 7. Patch MERGE: main + two patch files merged in order. +check("PatchMergeConf MERGE", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Scalar overridden by the first patch. + assert.equal(pm.data().name, "orange"); + // itemMap key 999 contributed by the second patch (merge, not replace). + assert.ok(pm.get1(999)); + assert.ok(pm.get1(1)); +}); + +// 8. LoadMode.ONLY_MAIN ignores the patch files. +check("PatchMergeConf ONLY_MAIN", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_MAIN, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + assert.equal(pm.data().name, "apple"); + assert.equal(pm.get1(999), undefined); +}); + +// 9. PATCH_MERGE golden: conf + patchconf merged must equal the canonical +// patchresult, checked as a whole (mirrors Go/C#'s proto.Equal comparison). +check("RecursivePatchConf MERGE golden", () => { + const got = new RecursivePatchConf(); + got.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + + // The golden file is itself a RecursivePatchConf sheet (PATCH_MERGE), so load + // it with no patch sources to get the main file content verbatim. + const expected = new RecursivePatchConf(); + expected.load(patchResult, Format.JSON, { ignoreUnknownFields: true }); + + assert.ok( + equals(protoconf.RecursivePatchConfSchema, got.message(), expected.message()), + "patched RecursivePatchConf does not match the golden patchresult", + ); +}); + +// 10. PATCH_REPLACE: the sheet-level option replaces the whole message with the +// last patch file, rather than merging field-by-field. +check("PatchReplaceConf PATCH_REPLACE", () => { + const pr = new PatchReplaceConf(); + pr.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + // Main file has name "apple" / priceList [10,100]; the patch fully replaces + // it with name "orange" / priceList [20,200] (replace, not append). + assert.equal(pr.data().name, "orange"); + assert.deepEqual(pr.data().priceList.map(Number), [20, 200]); +}); + +// 11. LoadMode.ONLY_PATCH starts from an empty message and applies patches only. +check("PatchMergeConf ONLY_PATCH", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_PATCH, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Name comes from the first patch (second patch carries no name field), and + // both itemMap entries come purely from the patches, not the main file. + assert.equal(pm.data().name, "orange"); + assert.ok(pm.get1(1)); + assert.ok(pm.get1(999)); +}); + +// 12. BIN load from a non-existent directory throws (mirrors C#'s failure case). +check("HeroConf BIN load failure", () => { + const hero = new HeroConf(); + assert.throws(() => hero.load(join(testdata, "no-such-bin-dir"), Format.BIN)); +}); + +console.log(`\nAll ${passed} smoke checks passed.`); diff --git a/test/ts-tableau-loader/tsconfig.json b/test/ts-tableau-loader/tsconfig.json new file mode 100644 index 0000000..fc607f5 --- /dev/null +++ b/test/ts-tableau-loader/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["tableau", "protoconf", "tests"] +} diff --git a/test_make.py b/test_make.py index a1acfcc..dd076e2 100644 --- a/test_make.py +++ b/test_make.py @@ -59,6 +59,7 @@ def test_typed_accessors(self): assert v.go_version == v.raw["GO_VERSION"] assert v.buf_version == v.raw["BUF_VERSION"] assert v.dotnet_version == v.raw["DOTNET_VERSION"] + assert v.node_version == v.raw["NODE_VERSION"] assert v.cmake_version == v.raw["CMAKE_VERSION"] # default_variant is lowercased. assert v.default_variant == v.raw["DEFAULT_VARIANT"].lower() @@ -68,6 +69,23 @@ def test_typed_accessors(self): assert v.protobuf_version == v.raw[f"{prefix}_PROTOBUF_VERSION"] assert v.vcpkg_baseline_commit == v.raw[f"{prefix}_VCPKG_BASELINE_COMMIT"] + def test_node_version_accessor(self): + # NODE_VERSION feeds the TypeScript harness toolchain (NodeSource apt + # repo `setup_${NODE_VERSION}.x`, brew `node@${NODE_VERSION}`). It's a + # major (e.g. "20"), so 1-3 dot-separated numeric segments are valid. + v = make.Versions.load(REPO_ROOT) + assert v.node_version == v.raw["NODE_VERSION"] + parts = v.node_version.split(".") + assert 1 <= len(parts) <= 3 + for p in parts: + assert p.isdigit(), f"Non-numeric node version segment: {p}" + + def test_node_version_missing_is_none(self): + # A versions.env without NODE_VERSION yields None (accessor is a + # plain .get, so absence is not an error). + v = make.Versions(raw={}) + assert v.node_version is None + def test_variants_enumerates_all_rows(self): v = make.Versions.load(REPO_ROOT) variants = v.variants() @@ -701,6 +719,12 @@ def test_csharp(self): == REPO_ROOT / "test" / "csharp-tableau-loader" ) + def test_ts(self): + assert ( + make._lang_dir(REPO_ROOT, "ts") + == REPO_ROOT / "test" / "ts-tableau-loader" + ) + # --------------------------------------------------------------------------- # Subprocess helpers @@ -750,6 +774,7 @@ def test_version_prints_versions_env(self): "LEGACY_V3_PROTOBUF_VERSION", "LEGACY_V3_VCPKG_BASELINE_COMMIT", "DOTNET_VERSION", + "NODE_VERSION", "CMAKE_VERSION", ): assert key in proc.stdout, f"--version missing {key}" @@ -991,6 +1016,39 @@ def test_test_lang_csharp_filter(self): assert "FullyQualifiedName~HubTest.Load" in proc.stdout +class TestDryRunTs: + def test_test_lang_ts_default(self): + proc = run_make("--dry-run", "test", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # Codegen (Go plugins + protobuf-es remote plugin) runs first. + assert "buf generate .." in out + # Deps install via the reproducible `npm ci` (lockfile is committed). + assert "npm ci" in out + # Type-check, then smoke. + assert "npm run check" in out + assert "npm run smoke" in out + + def test_build_lang_ts_skips_smoke(self): + proc = run_make("--dry-run", "build", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # build == type-check only: buf generate + install + check, no smoke. + assert "buf generate .." in out + assert "npm run check" in out + for line in out.splitlines(): + assert "npm run smoke" not in line, f"build must not run smoke: {line}" + + def test_generate_lang_ts(self): + proc = run_make("--dry-run", "generate", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # generate is codegen only — no npm steps. + assert "buf generate .." in out + for line in out.splitlines(): + assert "npm" not in line, f"generate must not invoke npm: {line}" + + class TestDryRunGenerateAndBuild: def test_generate_lang_go(self): proc = run_make("--dry-run", "generate", "--lang", "go") @@ -1037,14 +1095,24 @@ def test_clean_csharp(self): assert "obj" in out assert "protoconf" in out + def test_clean_ts(self): + proc = run_make("--dry-run", "clean", "--lang", "ts") + assert proc.returncode == 0 + out = proc.stdout + # Wipes generated dirs + installed deps. + assert "protoconf" in out + assert "tableau" in out + assert "node_modules" in out + def test_clean_all(self): proc = run_make("--dry-run", "clean", "--all") assert proc.returncode == 0 out = proc.stdout - # Should mention dirs from at least cpp, csharp, go. + # Should mention dirs from at least cpp, csharp, go, ts. assert "cpp-tableau-loader" in out assert "csharp-tableau-loader" in out assert "go-tableau-loader" in out + assert "ts-tableau-loader" in out # --------------------------------------------------------------------------- @@ -1220,3 +1288,23 @@ def test_dotnet_winget_id_uses_versions_env_major(self, monkeypatch, capsys): out = capsys.readouterr().out assert "Microsoft.DotNet.SDK.9" in out assert "Microsoft.DotNet.SDK.8" not in out + + def test_installs_node_for_ts_lang(self, monkeypatch, capsys): + """--lang ts on Windows must install Node via winget (LTS package) + when no node is already on PATH.""" + ctx = self._windows_ctx(monkeypatch) + args = type("Args", (), {"lang": "ts", "skip_vcpkg": True})() + rc = make.cmd_setup(args, ctx) + assert rc == 0 + out = capsys.readouterr().out + assert "OpenJS.NodeJS.LTS" in out, "--lang ts on Windows must install Node" + + def test_no_node_install_for_non_ts_lang(self, monkeypatch, capsys): + """Node must NOT be installed when ts isn't among the targets — the + winget Node step is gated on `ts in langs`.""" + ctx = self._windows_ctx(monkeypatch) + args = type("Args", (), {"lang": "go", "skip_vcpkg": True})() + rc = make.cmd_setup(args, ctx) + assert rc == 0 + out = capsys.readouterr().out + assert "OpenJS.NodeJS.LTS" not in out From e10838421e4ebada7bd49e1e7f97e0c3042361a1 Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Thu, 11 Jun 2026 20:52:56 +0800 Subject: [PATCH 2/7] chore(ts): trim CI Node matrix, fix npm.cmd test, drop _lab scratch - testing-ts: narrow node-version matrix to the pinned LTS (20) only. - test_make: normalize Windows npm.cmd shim to npm in TS dry-run asserts. - remove the _lab/ts proof-of-concept and its .gitignore entry. --- .github/workflows/testing-ts.yml | 7 +- .gitignore | 1 - _lab/ts/buf.gen.yaml | 25 -- _lab/ts/package-lock.json | 575 ------------------------------- _lab/ts/package.json | 19 - _lab/ts/src/poc.ts | 121 ------- _lab/ts/tsconfig.json | 14 - test_make.py | 7 +- 8 files changed, 8 insertions(+), 761 deletions(-) delete mode 100644 _lab/ts/buf.gen.yaml delete mode 100644 _lab/ts/package-lock.json delete mode 100644 _lab/ts/package.json delete mode 100644 _lab/ts/src/poc.ts delete mode 100644 _lab/ts/tsconfig.json diff --git a/.github/workflows/testing-ts.yml b/.github/workflows/testing-ts.yml index 8ab2a8b..f6ed922 100644 --- a/.github/workflows/testing-ts.yml +++ b/.github/workflows/testing-ts.yml @@ -18,10 +18,9 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - # Supported active Node.js LTS lines plus current. The devcontainer - # and make.py pin NODE_VERSION (loaded below) for local parity; CI - # additionally fans out across majors to catch ESM/tsx regressions. - node-version: ["18", "20", "22"] + # Pin to the active LTS line (Node 20), matching NODE_VERSION used by + # the devcontainer and make.py for local/CI parity. + node-version: ["20"] name: test (${{ matrix.os }}, node ${{ matrix.node-version }}) runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 7d8c94f..f4bab22 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ cmd/protoc-gen-go-tableau-loader/protoc-gen-go-tableau-loader cmd/protoc-gen-csharp-tableau-loader/protoc-gen-csharp-tableau-loader test/go-tableau-loader/go-tableau-loader -_lab/ts/src/protoconf coverage.txt !test/testdata/bin/ test/csharp-tableau-loader/protoconf diff --git a/_lab/ts/buf.gen.yaml b/_lab/ts/buf.gen.yaml deleted file mode 100644 index 88046fd..0000000 --- a/_lab/ts/buf.gen.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: v2 -# PoC: mirror the existing per-language buf.gen.yaml pattern, where a "base" -# plugin emits the message types and (later) protoc-gen-ts-tableau-loader wraps them -# into Messager/Hub/index code — exactly like protocolbuffers/go + -# protoc-gen-go-tableau-loader on the Go side. -# -# Here only the base plugin (protobuf-es) runs; it generates the protobuf -# message classes we deserialize tableau's protojson output into. -plugins: - # Run the protobuf-es generator as a BSR remote plugin (mirrors the Go side's - # `remote: buf.build/protocolbuffers/go`). Keep this version in lockstep with - # the @bufbuild/protobuf runtime in package.json: the generated *_pb.ts import - # from @bufbuild/protobuf at runtime, so generator and runtime must match. - - remote: buf.build/bufbuild/es:v2.2.3 - out: src/protoconf - # The proto files use tableau.* extension options, so the generated - # message modules import the tableau/protobuf descriptors; pull those - # dependencies in here (mirrors include_imports in the cpp/csharp - # buf.gen.yaml) instead of passing --include-imports on the CLI. - include_imports: true - opt: - - target=ts - # Emit explicit ".js" extensions on relative imports so the output is - # valid under TS's nodenext/node16 ESM resolution (and plain Node ESM). - - import_extension=js diff --git a/_lab/ts/package-lock.json b/_lab/ts/package-lock.json deleted file mode 100644 index 4bd2123..0000000 --- a/_lab/ts/package-lock.json +++ /dev/null @@ -1,575 +0,0 @@ -{ - "name": "tableau-ts-lab", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "tableau-ts-lab", - "version": "1.0.0", - "dependencies": { - "@bufbuild/protobuf": "^2.2.3" - }, - "devDependencies": { - "@types/node": "^20.19.43", - "tsx": "^4.19.2", - "typescript": "^5.6.3" - } - }, - "node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/_lab/ts/package.json b/_lab/ts/package.json deleted file mode 100644 index 6f2e6c4..0000000 --- a/_lab/ts/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "tableau-ts-lab", - "private": true, - "type": "module", - "version": "1.0.0", - "description": "PoC: validate protobuf-es as the codegen base for protoc-gen-ts-tableau-loader", - "scripts": { - "generate": "buf generate ../../test", - "poc": "tsx src/poc.ts" - }, - "dependencies": { - "@bufbuild/protobuf": "^2.2.3" - }, - "devDependencies": { - "@types/node": "^20.19.43", - "tsx": "^4.19.2", - "typescript": "^5.6.3" - } -} diff --git a/_lab/ts/src/poc.ts b/_lab/ts/src/poc.ts deleted file mode 100644 index 3ba3282..0000000 --- a/_lab/ts/src/poc.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * PoC — validate that protobuf-es (@bufbuild/protobuf) can faithfully consume - * the protojson that tableau (github.com/tableauio/tableau) emits, so it can - * serve as the codegen base for `protoc-gen-ts-tableau-loader`. - * - * Run: npm run generate && npm run poc - * - * What this exercises (the things that actually decide the selection): - * 1. Canonical proto3 JSON ("protojson") parsing via fromJson(Schema, ...). - * 2. Mixed field-name casing in one file: ItemConf.json has `itemMap`, - * `extTypeList`, `nameList` (camelCase) AND `param_list` (original - * snake_case). proto3 JSON requires BOTH to be accepted — protobuf-es - * does; protobufjs's fromObject does not (it would silently drop one). - * 3. 64-bit ints as JSON strings: ThemeConf.Theme.value is uint64, encoded - * as "1" in JSON, must parse into a bigint. - * 4. Well-known types: ItemConf.Item.expiry is google.protobuf.Timestamp, - * encoded as an RFC-3339 string. - * 5. Binary round-trip (toBinary -> fromBinary) — the `.binpb` load path. - * 6. Re-emitting canonical protojson via toJson (note snake `param_list` - * comes back as camel `paramList`). - */ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { - fromJson, - toJson, - toBinary, - fromBinary, - type JsonValue, -} from "@bufbuild/protobuf"; - -import { ItemConfSchema } from "./protoconf/item_conf_pb.js"; -import { ThemeConfSchema } from "./protoconf/test_conf_pb.js"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const CONF = join(HERE, "../../../test/testdata/conf"); - -let failures = 0; -function check(label: string, cond: unknown): void { - const ok = Boolean(cond); - console.log(` ${ok ? "PASS" : "FAIL"} ${label}`); - if (!ok) failures++; -} - -// JSON.stringify helper that survives bigint (64-bit) fields. -const bigintSafe = (_k: string, v: unknown) => - typeof v === "bigint" ? v.toString() : v; - -function readJson(file: string): JsonValue { - return JSON.parse(readFileSync(join(CONF, file), "utf8")) as JsonValue; -} - -// --- 1) ItemConf: the rich case (maps / enums / WKT / mixed casing) -------- -console.log("\n[ItemConf] protojson -> message"); -const itemConf = fromJson(ItemConfSchema, readJson("ItemConf.json")); -const apple = itemConf.itemMap["1"]; - -check("map parsed; key '1' present", apple !== undefined); -check("camelCase 'name' -> apple", apple?.name === "apple"); -check( - "snake_case 'param_list' accepted -> [1,2,3]", - JSON.stringify(apple?.paramList) === "[1,2,3]", -); -check( - "camelCase 'extTypeList' accepted -> 2 entries", - apple?.extTypeList.length === 2, -); -check( - "nested 'path.nameList' (camel) -> ['icon.png']", - apple?.path?.nameList[0] === "icon.png", -); -check("enum 'type' parsed by name -> non-zero", (apple?.type ?? 0) !== 0); -check( - "WKT Timestamp 'expiry' parsed -> seconds > 0", - (apple?.expiry?.seconds ?? 0n) > 0n, -); - -// --- 2) ThemeConf: uint64-as-string ----------------------------------------- -console.log("\n[ThemeConf] uint64 encoded as JSON string"); -const themeConf = fromJson(ThemeConfSchema, readJson("ThemeConf.json")); -const theme1 = themeConf.themeMap["theme1"]; -check("map key 'theme1' present", theme1 !== undefined); -check('uint64 "1" -> bigint 1n', theme1?.value === 1n); - -// --- 3) binary round-trip (the .binpb load path) ---------------------------- -console.log("\n[ItemConf] binary round-trip"); -const bin = toBinary(ItemConfSchema, itemConf); -const back = fromBinary(ItemConfSchema, bin); -check("toBinary produced bytes", bin.length > 0); -check( - "fromBinary preserves apple.name", - back.itemMap["1"]?.name === "apple", -); -check( - "fromBinary preserves paramList", - JSON.stringify(back.itemMap["1"]?.paramList) === "[1,2,3]", -); - -// --- 4) re-emit canonical protojson ----------------------------------------- -console.log("\n[ItemConf] message -> canonical protojson (toJson)"); -const reJson = toJson(ItemConfSchema, itemConf) as Record; -const reApple = reJson.itemMap["1"]; -check( - "re-emitted with camelCase 'paramList' (was snake in input)", - Array.isArray(reApple.paramList), -); -check( - "re-emitted Timestamp as RFC-3339 string", - typeof reApple.expiry === "string" && reApple.expiry.includes("T"), -); -console.log( - " sample:", - JSON.stringify(reApple, bigintSafe).slice(0, 120) + " ...", -); - -// --- verdict ---------------------------------------------------------------- -console.log( - `\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`, -); -process.exit(failures === 0 ? 0 : 1); diff --git a/_lab/ts/tsconfig.json b/_lab/ts/tsconfig.json deleted file mode 100644 index e67bc94..0000000 --- a/_lab/ts/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["src"] -} diff --git a/test_make.py b/test_make.py index dd076e2..b1425ed 100644 --- a/test_make.py +++ b/test_make.py @@ -1020,7 +1020,9 @@ class TestDryRunTs: def test_test_lang_ts_default(self): proc = run_make("--dry-run", "test", "--lang", "ts") assert proc.returncode == 0 - out = proc.stdout + # On Windows npm is the `npm.cmd` batch shim; normalize so the + # assertions below are platform-agnostic. + out = proc.stdout.replace("npm.cmd", "npm") # Codegen (Go plugins + protobuf-es remote plugin) runs first. assert "buf generate .." in out # Deps install via the reproducible `npm ci` (lockfile is committed). @@ -1032,7 +1034,8 @@ def test_test_lang_ts_default(self): def test_build_lang_ts_skips_smoke(self): proc = run_make("--dry-run", "build", "--lang", "ts") assert proc.returncode == 0 - out = proc.stdout + # Normalize the Windows `npm.cmd` shim to `npm`. + out = proc.stdout.replace("npm.cmd", "npm") # build == type-check only: buf generate + install + check, no smoke. assert "buf generate .." in out assert "npm run check" in out From 221970682acad4eaab880dc9568678c5930b0d1f Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Fri, 12 Jun 2026 21:22:39 +0800 Subject: [PATCH 3/7] test(ts): split smoke tests into load/get/ordered_map/index suites with shared harness Reorganize the TS test suite into per-feature files (load/get/ordered_map/index) backed by a shared harness, and refresh the protoc-gen-ts-tableau-loader generator and generated tableau sources accordingly. --- .../embed/util.pc.ts | 203 ++++ .../helper/helper.go | 109 +- cmd/protoc-gen-ts-tableau-loader/index.go | 954 +++++++++++++++ cmd/protoc-gen-ts-tableau-loader/messager.go | 67 +- .../ts-tableau-loader/tableau/hero_conf.pc.ts | 20 +- .../tableau/index_conf.pc.ts | 1077 ++++++++++++++++- .../ts-tableau-loader/tableau/item_conf.pc.ts | 398 +++++- .../tableau/patch_conf.pc.ts | 28 +- .../ts-tableau-loader/tableau/test_conf.pc.ts | 832 ++++++++++++- test/ts-tableau-loader/tableau/util.pc.ts | 203 ++++ test/ts-tableau-loader/tests/get.test.ts | 55 + test/ts-tableau-loader/tests/harness.ts | 53 + test/ts-tableau-loader/tests/index.test.ts | 118 ++ test/ts-tableau-loader/tests/load.test.ts | 149 +++ .../tests/ordered_map.test.ts | 64 + test/ts-tableau-loader/tests/smoke.ts | 190 +-- 16 files changed, 4251 insertions(+), 269 deletions(-) create mode 100644 cmd/protoc-gen-ts-tableau-loader/index.go create mode 100644 test/ts-tableau-loader/tests/get.test.ts create mode 100644 test/ts-tableau-loader/tests/harness.ts create mode 100644 test/ts-tableau-loader/tests/index.test.ts create mode 100644 test/ts-tableau-loader/tests/load.test.ts create mode 100644 test/ts-tableau-loader/tests/ordered_map.test.ts diff --git a/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts index d1db8d8..2069c03 100644 --- a/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts +++ b/cmd/protoc-gen-ts-tableau-loader/embed/util.pc.ts @@ -176,3 +176,206 @@ function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { } } } + +// --------------------------------------------------------------------------- +// Index container runtime +// +// Helpers shared by generated loaders to build index, ordered index and +// ordered map containers. +// --------------------------------------------------------------------------- + +/** + * makeIndexKey serializes a tuple of multi-column index key parts into a unique + * string so it can be used as a Map key with by-value equality (JS Maps compare + * object keys by reference, which would break composite keys). + * + * Each part is tagged with its runtime type to avoid collisions between values + * that stringify to the same text (e.g. the number 1 and the string "1"), and + * parts are joined with a NUL separator that cannot appear in normal strings. + * + * Module-private: it is an implementation detail of TupleKeyMap (the only + * caller). Generated loaders never serialize keys themselves; they go through + * TupleKeyMap, so this is intentionally not exported. + */ +function makeIndexKey(parts: readonly unknown[]): string { + return parts.map((p) => `${typeof p}:${String(p)}`).join("\u0000"); +} + +/** + * compareValues compares two ordered scalar key parts of the same logical type + * (number, bigint, string or boolean), returning a negative/zero/positive + * number suitable for Array.prototype.sort. + */ +export function compareValues(a: unknown, b: unknown): number { + if (typeof a === "bigint" || typeof b === "bigint") { + const x = BigInt(a as never); + const y = BigInt(b as never); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "string" || typeof b === "string") { + const x = String(a); + const y = String(b); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "boolean" || typeof b === "boolean") { + return (a ? 1 : 0) - (b ? 1 : 0); + } + return (a as number) - (b as number); +} + +/** + * compareTuples compares two equal-length tuples of ordered scalar parts + * lexicographically. + */ +export function compareTuples(a: readonly unknown[], b: readonly unknown[]): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const c = compareValues(a[i], b[i]); + if (c !== 0) return c; + } + return a.length - b.length; +} + +/** + * sortMapByKey sorts a Map in place by key using the given comparator and + * returns the same Map instance. JS Maps preserve insertion order, so the + * entries are re-inserted in sorted order (an "ordered map"). Sorting in place + * (rather than returning a new Map) keeps the Map reference stable across + * reloads, so views cached over it (e.g. TupleKeyMap) stay valid. + */ +export function sortMapByKey(map: Map, cmp: (a: K, b: K) => number): Map { + const entries = [...map.entries()].sort((x, y) => cmp(x[0], y[0])); + map.clear(); + for (const [k, v] of entries) map.set(k, v); + return map; +} + +/** + * OrderedMapValue is the value stored at a non-leaf level of a nested ordered + * map. It pairs the next-level ordered (sub-)map with the message value at the + * current level, mirroring the Pair node the Go / C++ / C# + * loaders expose (Go .First/.Second, C# .Item1/.Item2, C++ .first/.second), so + * callers can both descend into the sub-map (.first) and read the level's own + * value (.second). Leaf levels store the plain value directly, without this + * wrapper. + * + * Type parameters: + * - M: the next-level ordered map type (a Map keyed in sorted order). + * - E: the message value at the current level. + */ +export interface OrderedMapValue { + /** first is the next-level ordered (sub-)map, sorted by key. */ + first: M; + /** second is the value at the current level. */ + second: E; +} + +/** + * TupleKeyMap is the multi-column index / ordered-index container. A composite + * key (e.g. [param, extType]) cannot be used directly as a JS Map key (Maps + * compare object keys by reference), so it owns an internal Map keyed by the + * opaque serialized string of the key columns (see makeIndexKey) plus a side + * table mapping that string back to the original key tuple. Lookups and + * iteration accept / return by-value tuple keys, mirroring the structured + * composite keys the Go / C++ / C# loaders expose for multi-column indexes + * (where the map key is a comparable key struct). + * + * Unlike a plain read-only view, it owns its storage and exposes a small build + * API (clear / getOrSet / sortKeys) used by the generated processAfterLoad, so + * a messager holds this single container per multi-column index (or per + * multi-key leveled container) instead of a raw Map, a separate serialized-key + * -> tuple side table, and a cached view. It is mutated in place across + * reloads, so its reference stays stable and callers holding the container + * returned by a finder stay valid. + * + * Type parameters: + * - K: the readonly tuple of key columns (e.g. readonly [id: number, + * name: string]); the generated loaders pass a named alias (e.g. + * ItemConf.Index_AwardItemKey) so lookups / iteration are fully typed. K is + * purely compile-time: the runtime always serializes via makeIndexKey, so + * the key type carries no runtime cost. + * - E: the value type stored under each key tuple. This is a plain tuple-keyed + * map (one key -> one value), so the value is whatever the caller stores: + * a value list (E = V[]) for a leaf multi-column index, or an inner index + * container for a multi-key leveled container. + */ +export class TupleKeyMap { + private readonly map = new Map(); + private readonly tuples = new Map(); + + /** size returns the number of key buckets. */ + get size(): number { + return this.map.size; + } + + /** clear empties the container (called at the start of each (re)build). */ + clear(): void { + this.map.clear(); + this.tuples.clear(); + } + + /** + * getOrSet returns the value stored under the given key columns, creating and + * inserting it via make() on first access (and recording the original key + * tuple then, for sortKeys and by-value key iteration). makeIndexKey is + * computed exactly once. The build side is generator-driven, so keyParts is + * loosely typed; the query side (get/has/keys/entries) stays strongly typed. + */ + getOrSet(keyParts: readonly unknown[], make: () => E): E { + const k = makeIndexKey(keyParts); + let v = this.map.get(k); + if (v === undefined) { + v = make(); + this.map.set(k, v); + this.tuples.set(k, keyParts); + } + return v; + } + + /** + * sortKeys reorders the buckets by comparing their key tuples + * lexicographically (used by ordered indexes). JS Maps preserve insertion + * order, so entries are re-inserted in sorted order. Sorting in place keeps + * the container reference stable across reloads. + */ + sortKeys(): void { + const entries = [...this.map.entries()].sort((x, y) => + compareTuples(this.tuples.get(x[0]) ?? [], this.tuples.get(y[0]) ?? []), + ); + this.map.clear(); + for (const [k, v] of entries) this.map.set(k, v); + } + + /** get returns the value stored under the given key columns, or undefined. */ + get(key: K): E | undefined { + return this.map.get(makeIndexKey(key)); + } + + /** has reports whether the given key columns are present. */ + has(key: K): boolean { + return this.map.has(makeIndexKey(key)); + } + + /** keys iterates the key-column tuples in container (sorted, if ordered) order. */ + *keys(): IterableIterator { + for (const k of this.map.keys()) { + yield (this.tuples.get(k) ?? []) as unknown as K; + } + } + + /** values iterates the values in container order. */ + values(): IterableIterator { + return this.map.values(); + } + + /** entries iterates [keyColumns, value] pairs in container order. */ + *entries(): IterableIterator<[K, E]> { + for (const [k, v] of this.map) { + yield [(this.tuples.get(k) ?? []) as unknown as K, v]; + } + } + + [Symbol.iterator](): IterableIterator<[K, E]> { + return this.entries(); + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go index 53903fb..3e084e1 100644 --- a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go +++ b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go @@ -117,6 +117,9 @@ type MapKey struct { // NeedToString reports whether the parameter must be stringified to index // the underlying map object (true for 64-bit / bool keys). NeedToString bool + // Fd is the map field descriptor this key belongs to (used to derive the + // leveled-container key alias name; may be nil for non-leveled keys). + Fd protoreflect.FieldDescriptor } // IndexExpr returns the expression used to index the underlying map object. @@ -195,6 +198,19 @@ func ParseMapKey(keyFd protoreflect.FieldDescriptor, name string) MapKey { return key } +// ParseLeveledMapPrefix returns the leveled-map name fragment used to build a +// leveled-container key alias, mirroring the Go/C++/C# loaders. For a +// message-valued map it is the value message's name local to the messager +// (joined with "_", e.g. "Activity_Chapter"); for a scalar-valued map it is the +// value kind string. md is the owning messager message. +func ParseLeveledMapPrefix(md protoreflect.MessageDescriptor, mapFd protoreflect.FieldDescriptor) string { + if mapFd.MapValue().Kind() == protoreflect.MessageKind { + localMsgProtoName := strings.TrimPrefix(string(mapFd.MapValue().Message().FullName()), string(md.FullName())+".") + return strings.ReplaceAll(localMsgProtoName, ".", "_") + } + return mapFd.MapValue().Kind().String() +} + // ParseMapFieldNameAsFuncParam returns a safe lowerCamelCase getter parameter // name for a map field key. If the map value is a message type, the first field // name of the value message is used; otherwise the tableau field option "key" @@ -213,10 +229,97 @@ func ParseMapFieldNameAsFuncParam(fd protoreflect.FieldDescriptor) string { return escapeIdentifier(strcase.ToLowerCamel(name)) } -// FieldLocalName returns the protobuf-es local (JS property) name for a field, -// which is the lowerCamelCase form of the protobuf field name. +// FieldLocalName returns the protobuf-es local (JS property) name for a field. +// +// IMPORTANT: protobuf-es derives a field's JS property name with protoCamelCase +// (NOT strcase): it never lowercases the first letter, preserves existing +// capitals, and only capitalizes the letter following an underscore or digit. +// Reserved object properties (constructor/toString/toJSON/valueOf) get a "$" +// suffix. We must match this exactly so generated property access (e.g. for +// index fields like "HTTPServer", "SEASON_RANK", "fight_1v1_") lines up with +// the protobuf-es generated types. func FieldLocalName(fd protoreflect.FieldDescriptor) string { - return strcase.ToLowerCamel(string(fd.Name())) + return safeObjectProperty(protoCamelCase(string(fd.Name()))) +} + +// protoCamelCase converts a protobuf field name to its protobuf-es local name, +// matching @bufbuild/protobuf's protoCamelCase implementation. +func protoCamelCase(snakeCase string) string { + var b strings.Builder + capNext := false + for _, c := range snakeCase { + switch { + case c == '_': + capNext = true + case c >= '0' && c <= '9': + b.WriteRune(c) + capNext = false + default: + if capNext { + capNext = false + if c >= 'a' && c <= 'z' { + c = c - 'a' + 'A' + } + } + b.WriteRune(c) + } + } + return b.String() +} + +// reservedObjectProperties are JS object properties protobuf-es escapes with a +// trailing "$" to avoid clashing with built-ins. +var reservedObjectProperties = map[string]bool{ + "constructor": true, "toString": true, "toJSON": true, "valueOf": true, +} + +// safeObjectProperty appends "$" to reserved object property names, matching +// protobuf-es's safeObjectProperty. +func safeObjectProperty(name string) string { + if reservedObjectProperties[name] { + return name + "$" + } + return name +} + +// IndexFieldNameAsKeyStructFieldName returns the CamelCase logical name of an +// index field, used to derive parameter names. For list fields the tableau +// field option name is used; otherwise the protobuf field name is used. +func IndexFieldNameAsKeyStructFieldName(fd protoreflect.FieldDescriptor) string { + if fd.IsList() { + opts := fd.Options().(*descriptorpb.FieldOptions) + fdOpts := proto.GetExtension(opts, tableaupb.E_Field).(*tableaupb.FieldOptions) + return strcase.ToCamel(fdOpts.GetName()) + } + return strcase.ToCamel(string(fd.Name())) +} + +// IndexFieldNameAsFuncParam returns a safe lowerCamelCase finder parameter name +// for an index field. +func IndexFieldNameAsFuncParam(fd protoreflect.FieldDescriptor) string { + return escapeIdentifier(strcase.ToLowerCamel(IndexFieldNameAsKeyStructFieldName(fd))) +} + +// TSEmptyValue returns the TypeScript empty/default literal for a field's type, +// used as the fallback when accessing a possibly-absent nested index field. +func TSEmptyValue(fd protoreflect.FieldDescriptor) string { + switch fd.Kind() { + case protoreflect.BoolKind: + return "false" + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Uint32Kind, protoreflect.Fixed32Kind, + protoreflect.FloatKind, protoreflect.DoubleKind, protoreflect.EnumKind: + return "0" + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, + protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return "0n" + case protoreflect.StringKind: + return `""` + case protoreflect.BytesKind: + return "new Uint8Array()" + default: + return "undefined" + } } // tsReservedWords are TypeScript/JavaScript reserved words that cannot be used diff --git a/cmd/protoc-gen-ts-tableau-loader/index.go b/cmd/protoc-gen-ts-tableau-loader/index.go new file mode 100644 index 0000000..719fc12 --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/index.go @@ -0,0 +1,954 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/index" + "github.com/tableauio/loader/internal/loadutil" + "github.com/tableauio/loader/internal/options" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// indexGen generates index / ordered index / ordered map support for a single +// worksheet messager. +// +// It mirrors the leveled-container model of the Go / C# / C++ generators: an +// index may be declared on a column that lives at any container level, and for +// every map ancestor of that level a "leveled" container is generated so the +// index can be queried scoped to a specific upper map key (e.g. findItem1). +// +// JavaScript Maps compare object keys by reference, so a single upper key +// (k1) buckets into a native Map keyed by its raw value, while composite upper +// keys (k1..ki, i>=2) bucket into a TupleKeyMap keyed by the upper key tuple +// (which serializes it internally via makeIndexKey). +type indexGen struct { + g *protogen.GeneratedFile + descriptor *index.IndexDescriptor + message *protogen.Message + pkgs *pbPackages + + // keys holds, in order, the map key of every ancestor map level whose + // deeper level still needs an index. They are the upper keys used by + // leveled containers/finders. Names are deduplicated (e.g. id -> id3). + keys helper.MapKeySlice +} + +func newIndexGen(g *protogen.GeneratedFile, descriptor *index.IndexDescriptor, message *protogen.Message, pkgs *pbPackages) *indexGen { + x := &indexGen{g: g, descriptor: descriptor, message: message, pkgs: pkgs} + x.initLevelKeys() + return x +} + +// initLevelKeys collects the upper map keys used to build leveled containers. +func (x *indexGen) initLevelKeys() { + for lm := x.descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + if fd := lm.FD; fd != nil && fd.IsMap() { + // Only collect keys when a deeper level needs an index, because the + // keys are used solely for building leveled (upper-level) containers. + if !lm.NextLevel.NeedGenAnyIndex() { + break + } + paramName := helper.ParseMapFieldNameAsFuncParam(fd) + x.keys = x.keys.AddMapKey(helper.MapKey{ + ParamType: helper.ParseMapKey(fd.MapKey(), paramName).ParamType, + Name: paramName, + Fd: fd, + }) + } + } +} + +// firstMapField returns the first map field of a message descriptor, or nil if +// it has none. The ordered map walks the message tree along this first-map-field +// chain (independently of indexes), mirroring the Go / C++ / C# loaders. +func firstMapField(md protoreflect.MessageDescriptor) protoreflect.FieldDescriptor { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if fd.IsMap() { + return fd + } + } + return nil +} + +// nextLevelMapField returns the first map field of a map field's value message +// (the next ordered-map level down), or nil if the value is a scalar/enum or a +// message that holds no map (i.e. fd is the leaf level). +func nextLevelMapField(fd protoreflect.FieldDescriptor) protoreflect.FieldDescriptor { + if fd.MapValue().Kind() == protoreflect.MessageKind { + return firstMapField(fd.MapValue().Message()) + } + return nil +} + +func (x *indexGen) needIndex() bool { + return x.descriptor.LevelMessage.NeedGenIndex() && options.NeedGenIndex(x.message.Desc, options.LangTS) +} + +func (x *indexGen) needOrderedIndex() bool { + return x.descriptor.LevelMessage.NeedGenOrderedIndex() && options.NeedGenOrderedIndex(x.message.Desc, options.LangTS) +} + +func (x *indexGen) needOrderedMap() bool { + return firstMapField(x.message.Desc) != nil && options.NeedGenOrderedMap(x.message.Desc, options.LangTS) +} + +// needNestedOrderedMap reports whether the ordered map has 2+ levels, i.e. its +// value nodes are OrderedMapValue pairs (sub-map + value) rather than plain +// leaf values. Used to decide whether the OrderedMapValue runtime type must be +// imported. +func (x *indexGen) needNestedOrderedMap() bool { + return x.needOrderedMap() && nextLevelMapField(firstMapField(x.message.Desc)) != nil +} + +// NeedGenerate reports whether any index/ordered-index/ordered-map code is emitted. +func (x *indexGen) NeedGenerate() bool { + return x.needIndex() || x.needOrderedIndex() || x.needOrderedMap() +} + +// ---------------------------------------------------------------------------- +// Type / name helpers +// ---------------------------------------------------------------------------- + +// valueType returns the package-qualified TypeScript type of an index's value +// message (the map/list value at the index's level). +func (x *indexGen) valueType(idx *index.LevelIndex) string { + return x.pkgs.aliasOf(idx.MD) + "." + helper.LocalTypeName(idx.MD) +} + +// keyTSType returns the TypeScript type used as a single-column index key. +func (x *indexGen) keyTSType(fd protoreflect.FieldDescriptor) string { + switch fd.Kind() { + case protoreflect.EnumKind: + return x.pkgs.aliasOf(fd.Enum()) + "." + helper.LocalTypeName(fd.Enum()) + case protoreflect.MessageKind, protoreflect.GroupKind: + // Only Timestamp/Duration are valid index keys here; keyed by seconds. + return "bigint" + default: + s, _ := helper.ScalarTSType(fd.Kind()) + return s + } +} + +// indexKeyType returns the TypeScript Map key type for an index (single-column +// uses the column's scalar/enum type; multi-column uses a serialized string). +func (x *indexGen) indexKeyType(idx *index.LevelIndex) string { + if len(idx.ColFields) == 1 { + return x.keyTSType(idx.ColFields[0].FD) + } + return "string" +} + +// params builds the finder parameter slice for an index's columns. +func (x *indexGen) params(idx *index.LevelIndex) helper.MapKeySlice { + var keys helper.MapKeySlice + for _, field := range idx.ColFields { + keys = keys.AddMapKey(helper.MapKey{ + ParamType: x.keyTSType(field.FD), + Name: helper.IndexFieldNameAsFuncParam(field.FD), + }) + } + return keys +} + +// containerField returns the private container field name for index idx at +// leveled depth i (i==0 is the global container). +func (x *indexGen) containerField(idx *index.LevelIndex, ordered bool, i int) string { + base := "index" + idx.Name() + "Map" + if ordered { + base = "orderedIndex" + idx.Name() + "Map" + } + if i == 0 { + return "#" + base + } + return fmt.Sprintf("#%s%d", base, i) +} + +const orderedMapField = "#orderedMap" + +// ---------------------------------------------------------------------------- +// Type aliases +// +// Mirroring the named container types of the C#/C++/Go loaders, every index / +// ordered index / ordered map container gets a readable type alias declared in +// a namespace merged with the messager class (e.g. ItemConf.Index_AwardItemMap). +// The namespace only contains `type` members, so it is fully erased at compile +// time (no runtime overhead) while making the finder signatures self-documenting. +// ---------------------------------------------------------------------------- + +// aliasName returns the bare type-alias name for an index's container (e.g. +// Index_AwardItemMap / OrderedIndex_ParamExtTypeMap), as declared inside the +// messager namespace. +func (x *indexGen) aliasName(idx *index.LevelIndex, ordered bool) string { + if ordered { + return "OrderedIndex_" + idx.Name() + "Map" + } + return "Index_" + idx.Name() + "Map" +} + +// indexType returns the messager-qualified alias used inside the class (e.g. +// ItemConf.Index_AwardItemMap), so field declarations and finder signatures +// reference the readable named type rather than an inline Map/TupleKeyMap. +func (x *indexGen) indexType(idx *index.LevelIndex, ordered bool) string { + return helper.MessagerName(x.message.Desc) + "." + x.aliasName(idx, ordered) +} + +// keyAliasName returns the bare composite-key tuple alias name for a +// multi-column index (e.g. Index_AwardItemKey / OrderedIndex_ParamExtTypeKey), +// declared inside the messager namespace. +func (x *indexGen) keyAliasName(idx *index.LevelIndex, ordered bool) string { + if ordered { + return "OrderedIndex_" + idx.Name() + "Key" + } + return "Index_" + idx.Name() + "Key" +} + +// keyAliasType returns the messager-qualified composite-key alias (e.g. +// ItemConf.Index_AwardItemKey), used to explicitly parameterize TupleKeyMap at +// its (invariant-in-K) construction sites. +func (x *indexGen) keyAliasType(idx *index.LevelIndex, ordered bool) string { + return helper.MessagerName(x.message.Desc) + "." + x.keyAliasName(idx, ordered) +} + +// keyTupleType returns the labeled readonly tuple type of a multi-column +// index's key columns (e.g. "readonly [id: number, name: string]"). The labels +// are purely for editor hints; assignability ignores them. +func (x *indexGen) keyTupleType(idx *index.LevelIndex) string { + var parts []string + for _, field := range idx.ColFields { + parts = append(parts, helper.IndexFieldNameAsFuncParam(field.FD)+": "+x.keyTSType(field.FD)) + } + return "readonly [" + strings.Join(parts, ", ") + "]" +} + +// upperKeyTupleType returns the labeled readonly tuple type of the first i +// upper-level map keys (e.g. "readonly [activityId: bigint, chapterId: +// number]"), used as the right-hand side of the leveled-container key alias. +func (x *indexGen) upperKeyTupleType(i int) string { + var parts []string + for _, k := range x.keys[:i] { + parts = append(parts, k.Name+": "+k.ParamType) + } + return "readonly [" + strings.Join(parts, ", ") + "]" +} + +// upperKeyAliasName returns the bare composite upper-key tuple alias name for +// the depth-i leveled containers (e.g. LevelIndex_Activity_ChapterKey), +// declared inside the messager namespace. It mirrors the Go loader's +// _LevelIndex_Key struct, the messager prefix being supplied +// by the enclosing namespace. Only meaningful for i>=2 (composite upper keys). +func (x *indexGen) upperKeyAliasName(i int) string { + return "LevelIndex_" + helper.ParseLeveledMapPrefix(x.message.Desc, x.keys[i-1].Fd) + "Key" +} + +// upperKeyAliasType returns the messager-qualified composite upper-key alias +// (e.g. ActivityConf.LevelIndex_Activity_ChapterKey), used to type and +// construct the i>=2 leveled TupleKeyMap containers. +func (x *indexGen) upperKeyAliasType(i int) string { + return helper.MessagerName(x.message.Desc) + "." + x.upperKeyAliasName(i) +} + +// newLeafContainer returns the construction expression for a multi-column +// index's leaf TupleKeyMap (key tuple -> value list), explicitly parameterized +// because TupleKeyMap is invariant in K and constructed empty. +func (x *indexGen) newLeafContainer(idx *index.LevelIndex, ordered bool) string { + return "new TupleKeyMap<" + x.keyAliasType(idx, ordered) + ", " + x.valueType(idx) + "[]>()" +} + +// orderedMapAliasOf returns the bare ordered-map alias name for a map field +// (e.g. OrderedMap_ActivityMap / OrderedMap_protoconf_SectionMap / +// OrderedMap_int32Map), using the same leveled-map prefix as the Go / C++ / C# +// loaders so the cross-language container names line up. +func (x *indexGen) orderedMapAliasOf(mapFd protoreflect.FieldDescriptor) string { + return "OrderedMap_" + helper.ParseLeveledMapPrefix(x.message.Desc, mapFd) + "Map" +} + +// orderedMapValueAliasOf returns the bare ordered-map value (pair) alias name +// for an intermediate map field (e.g. OrderedMap_ActivityValue), used for the +// OrderedMapValue node alias of a non-leaf level. +func (x *indexGen) orderedMapValueAliasOf(mapFd protoreflect.FieldDescriptor) string { + return "OrderedMap_" + helper.ParseLeveledMapPrefix(x.message.Desc, mapFd) + "Value" +} + +// orderedMapTypeOf returns the messager-qualified ordered-map alias for a map +// field (e.g. ActivityConf.OrderedMap_Activity_ChapterMap). +func (x *indexGen) orderedMapTypeOf(mapFd protoreflect.FieldDescriptor) string { + return helper.MessagerName(x.message.Desc) + "." + x.orderedMapAliasOf(mapFd) +} + +// orderedMapType returns the messager-qualified top-level (1st-level) ordered-map +// alias. +func (x *indexGen) orderedMapType() string { + return x.orderedMapTypeOf(firstMapField(x.message.Desc)) +} + +// genOrderedMapAliases recursively emits the nested ordered-map type aliases for +// the first-map-field chain rooted at md. Each level is a Map keyed in sorted +// order; an intermediate level's value is an OrderedMapValue pair (next-level +// sub-map + this level's message value), while the leaf level stores its plain +// value directly — mirroring the TreeMap> / leaf +// TreeMap shape of the Go / C++ / C# loaders. Deeper aliases are +// emitted first so the file reads inner-to-outer like the Go loader (all +// aliases share one namespace, so referencing order does not matter). +func (x *indexGen) genOrderedMapAliases(md protoreflect.MessageDescriptor, depth int) { + fd := firstMapField(md) + if fd == nil { + return + } + nextFd := nextLevelMapField(fd) + if fd.MapValue().Kind() == protoreflect.MessageKind { + x.genOrderedMapAliases(fd.MapValue().Message(), depth+1) + } + k := helper.ParseMapKey(fd.MapKey(), "").ParamType + alias := x.orderedMapAliasOf(fd) + ordinal := loadutil.Ordinal(depth) + if nextFd != nil { + valueAlias := x.orderedMapValueAliasOf(fd) + nextMap := x.orderedMapAliasOf(nextFd) + curr := mapValueType(fd, x.pkgs) + x.g.P(helper.Indent(1), "/** ", valueAlias, " is a ", ordinal, "-level node: its next-level sub-map (first) plus this level's value (second). */") + x.g.P(helper.Indent(1), "export type ", valueAlias, " = OrderedMapValue<", nextMap, ", ", curr, ">;") + x.g.P(helper.Indent(1), "/** ", alias, " is the ", ordinal, "-level ordered map: key -> node (sorted by key). */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", k, ", ", valueAlias, ">;") + return + } + v := mapValueType(fd, x.pkgs) + x.g.P(helper.Indent(1), "/** ", alias, " is the ", ordinal, "-level (leaf) ordered map: key -> value (sorted by key). */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", k, ", ", v, ">;") +} + +// GenTypeAliases emits the messager namespace holding the index / ordered index +// / ordered map type aliases. Declaration-merged with the class, it is purely +// type-level and erased at runtime. +func (x *indexGen) GenTypeAliases() { + if !x.NeedGenerate() { + return + } + name := helper.MessagerName(x.message.Desc) + x.g.P() + x.g.P("// Type aliases for the index / ordered index / ordered map containers,") + x.g.P("// mirroring the named container types of the other-language loaders so the") + x.g.P("// finder signatures read clearly (e.g. ", name, ".Index_XxxMap).") + x.g.P("export namespace ", name, " {") + if x.needOrderedMap() { + x.genOrderedMapAliases(x.message.Desc, 1) + } + // Composite upper-key tuple aliases shared by every depth-i (i>=2) leveled + // index / ordered-index container, mirroring the Go loader's + // _LevelIndex_Key structs. A single upper key (depth 1) + // buckets into a native Map by its raw value, so no alias is needed there. + for i := 2; i <= len(x.keys); i++ { + alias := x.upperKeyAliasName(i) + x.g.P(helper.Indent(1), "/** ", alias, " is the composite upper map key (k1..k", i, ") of the ", loadutil.Ordinal(i), "-level leveled containers. */") + x.g.P(helper.Indent(1), "export type ", alias, " = ", x.upperKeyTupleType(i), ";") + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genAliasDecl(idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genAliasDecl(idx, true) + }) + } + x.g.P("}") +} + +// genAliasDecl emits one index/ordered-index alias. Single-column indexes alias +// the native Map; multi-column indexes first declare a labeled readonly tuple +// for the composite key (e.g. Index_AwardItemKey = readonly [id: number, +// name: string]) and alias a TupleKeyMap parameterized by it, so the finders' +// keys are fully typed instead of an opaque unknown[]. TupleKeyMap is the +// owning multi-column container (key tuple -> value list), keyed internally by +// the opaque serialized composite key, which is an internal serialization +// detail. +func (x *indexGen) genAliasDecl(idx *index.LevelIndex, ordered bool) { + v := x.valueType(idx) + alias := x.aliasName(idx, ordered) + label := "index" + if ordered { + label = "ordered index" + } + if len(idx.ColFields) > 1 { + keyAlias := x.keyAliasName(idx, ordered) + x.g.P(helper.Indent(1), "/** ", keyAlias, " is the composite key of ", label, ": key(", idx.Index, "). */") + x.g.P(helper.Indent(1), "export type ", keyAlias, " = ", x.keyTupleType(idx), ";") + x.g.P(helper.Indent(1), "/** ", alias, " is the ", label, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "export type ", alias, " = TupleKeyMap<", keyAlias, ", ", v, "[]>;") + return + } + x.g.P(helper.Indent(1), "/** ", alias, " is the ", label, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "export type ", alias, " = Map<", x.indexKeyType(idx), ", ", v, "[]>;") +} + +// eachIndex iterates every index (or ordered index) across all levels. +func (x *indexGen) eachIndex(ordered bool, fn func(lm *index.LevelMessage, idx *index.LevelIndex)) { + for lm := x.descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + indexes := lm.Indexes + if ordered { + indexes = lm.OrderedIndexes + } + for _, idx := range indexes { + fn(lm, idx) + } + } +} + +// ---------------------------------------------------------------------------- +// Declarations +// ---------------------------------------------------------------------------- + +// GenDecls emits the private container field declarations. +func (x *indexGen) GenDecls() { + if x.needOrderedMap() { + x.g.P(helper.Indent(1), orderedMapField, ": ", x.orderedMapType(), " = new Map();") + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genContainerDecls(lm, idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genContainerDecls(lm, idx, true) + }) + } +} + +func (x *indexGen) genContainerDecls(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + at := x.indexType(idx, ordered) + multi := len(idx.ColFields) > 1 + // The global (0-level) container: single-column indexes use the native Map + // alias; multi-column indexes use the owning leaf TupleKeyMap (key tuple -> + // value list), which holds the serialized-key -> tuple side table + // internally. The field's alias annotation (at) resolves to the fully + // parameterized TupleKeyMap, so the constructor's type arguments are + // inferred from it contextually and need not be repeated on the `new` side. + if multi { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, 0), ": ", at, " = new TupleKeyMap();") + } else { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, 0), ": ", at, " = new Map();") + } + // Leveled containers bucket the global container alias under the upper map + // key(s): a single upper key (i==1) uses a native Map keyed by its raw + // value; composite upper keys (i>=2) use a TupleKeyMap keyed by the upper + // key tuple alias (serialized internally), so no makeIndexKey is needed at + // the call sites. + for i := 1; i < lm.LeveledContainerDepth(); i++ { + if i == 1 { + x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": Map<", x.keys[0].ParamType, ", ", at, "> = new Map();") + } else { + ut := x.upperKeyAliasType(i) + // The field carries the full TupleKeyMap annotation, so the + // constructor's type arguments are inferred from it contextually; + // repeating them on the `new` side would be redundant. + x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": TupleKeyMap<", ut, ", ", at, "> = new TupleKeyMap();") + } + } +} + +// ---------------------------------------------------------------------------- +// processAfterLoad body +// ---------------------------------------------------------------------------- + +// GenProcessAfterLoadBody emits the body of the processAfterLoad override +// (statements at indent level 2). +func (x *indexGen) GenProcessAfterLoadBody() { + if x.needOrderedMap() { + x.genOrderedMapLoader() + } + if x.needIndex() { + x.genIndexSection(false) + } + if x.needOrderedIndex() { + x.genIndexSection(true) + } +} + +func (x *indexGen) genOrderedMapLoader() { + x.g.P(helper.Indent(2), "// OrderedMap init.") + x.g.P(helper.Indent(2), "const orderedMap: ", x.orderedMapType(), " = new Map();") + x.genOrderedMapLoaderLevel(x.message.Desc, 1, "orderedMap", "this.#data", 2) + x.g.P(helper.Indent(2), "this.", orderedMapField, " = orderedMap;") +} + +// genOrderedMapLoaderLevel recursively emits the nested loops that fill one +// ordered-map level. It converts each string object-key, and at an intermediate +// level builds the next-level sub-map (recursing) then stores the +// {first: subMap, second: value} node; at the leaf level it stores the plain +// value. Each level is sorted by key once fully built, so iteration is +// ascending at every depth. +func (x *indexGen) genOrderedMapLoaderLevel(md protoreflect.MessageDescriptor, depth int, targetMap, sourceExpr string, indent int) { + fd := firstMapField(md) + field := helper.FieldLocalName(fd) + keyStr := fmt.Sprintf("k%dStr", depth) + keyVar := fmt.Sprintf("k%d", depth) + valVar := fmt.Sprintf("v%d", depth) + nextFd := nextLevelMapField(fd) + x.g.P(helper.Indent(indent), "for (const [", keyStr, ", ", valVar, "] of Object.entries(", sourceExpr, ".", field, ")) {") + x.g.P(helper.Indent(indent+1), "const ", keyVar, " = ", x.mapKeyConv(fd.MapKey(), keyStr), ";") + if nextFd != nil { + subMap := fmt.Sprintf("orderedMap%d", depth+1) + x.g.P(helper.Indent(indent+1), "const ", subMap, ": ", x.orderedMapTypeOf(nextFd), " = new Map();") + x.genOrderedMapLoaderLevel(fd.MapValue().Message(), depth+1, subMap, valVar, indent+1) + x.g.P(helper.Indent(indent+1), targetMap, ".set(", keyVar, ", { first: ", subMap, ", second: ", valVar, " });") + } else { + x.g.P(helper.Indent(indent+1), targetMap, ".set(", keyVar, ", ", valVar, ");") + } + x.g.P(helper.Indent(indent), "}") + x.g.P(helper.Indent(indent), "sortMapByKey(", targetMap, ", compareValues);") +} + +// mapKeyConv converts a string object-key (from Object.entries) into the proper +// map key type. +func (x *indexGen) mapKeyConv(keyFd protoreflect.FieldDescriptor, v string) string { + switch helper.ParseMapKey(keyFd, "").ParamType { + case "number": + return "Number(" + v + ")" + case "bigint": + return "BigInt(" + v + ")" + case "boolean": + return v + ` === "true"` + default: + return v + } +} + +func (x *indexGen) genIndexSection(ordered bool) { + label := "Index" + if ordered { + label = "OrderedIndex" + } + x.g.P(helper.Indent(2), "// ", label, " init.") + // Clear all containers. + x.eachIndex(ordered, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.g.P(helper.Indent(2), "this.", x.containerField(idx, ordered, 0), ".clear();") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + x.g.P(helper.Indent(2), "this.", x.containerField(idx, ordered, i), ".clear();") + } + }) + // Build containers via nested level traversal. + x.genLevelLoop(x.descriptor.LevelMessage, ordered, "this.#data", 2) + // Sort value lists by sorted columns. + x.eachIndex(ordered, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genValueListSorter(lm, idx, ordered) + }) + // Rebuild ordered maps sorted by key. + if ordered { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genOrderedRebuild(lm, idx) + }) + } +} + +// genLevelLoop recursively emits the nested for-loops that walk the level +// hierarchy and build the index containers. +func (x *indexGen) genLevelLoop(lm *index.LevelMessage, ordered bool, parentData string, indent int) { + if lm == nil { + return + } + need := lm.NeedGenIndex() + if ordered { + need = lm.NeedGenOrderedIndex() + } + if !need { + return + } + field := helper.FieldLocalName(lm.FD) + itemVar := fmt.Sprintf("item%d", lm.Depth) + if lm.FD.IsMap() { + needKey := lm.NeedMapKeyForIndex() + if ordered { + needKey = lm.NeedMapKeyForOrderedIndex() + } + if needKey { + keyStr := fmt.Sprintf("k%dStr", lm.MapDepth) + x.g.P(helper.Indent(indent), "for (const [", keyStr, ", ", itemVar, "] of Object.entries(", parentData, ".", field, ")) {") + x.g.P(helper.Indent(indent+1), "const k", lm.MapDepth, " = ", x.mapKeyConv(lm.FD.MapKey(), keyStr), ";") + } else { + x.g.P(helper.Indent(indent), "for (const ", itemVar, " of Object.values(", parentData, ".", field, ")) {") + } + } else { + x.g.P(helper.Indent(indent), "for (const ", itemVar, " of ", parentData, ".", field, " ?? []) {") + } + indexes := lm.Indexes + if ordered { + indexes = lm.OrderedIndexes + } + for _, idx := range indexes { + x.genOneIndexLoader(lm, idx, ordered, indent+1, itemVar) + } + x.genLevelLoop(lm.NextLevel, ordered, itemVar, indent+1) + x.g.P(helper.Indent(indent), "}") +} + +func (x *indexGen) genOneIndexLoader(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, indent int, itemVar string) { + label := "Index" + if ordered { + label = "OrderedIndex" + } + x.g.P(helper.Indent(indent), "{") + x.g.P(helper.Indent(indent+1), "// ", label, ": ", idx.Index) + if len(idx.ColFields) == 1 { + field := idx.ColFields[0] + access, isList := x.fieldAccess(itemVar, field) + if isList { + x.g.P(helper.Indent(indent+1), "for (const elem of ", access, ") {") + x.g.P(helper.Indent(indent+2), "const key = elem;") + x.emitPushAll(lm, idx, ordered, indent+2, "key", itemVar) + x.g.P(helper.Indent(indent+1), "}") + } else { + x.g.P(helper.Indent(indent+1), "const key = ", access, ";") + x.emitPushAll(lm, idx, ordered, indent+1, "key", itemVar) + } + } else { + x.genMultiCol(lm, idx, ordered, 0, indent+1, itemVar, nil) + } + x.g.P(helper.Indent(indent), "}") +} + +// genMultiCol recursively emits nested loops for list columns and finally +// builds the key-column tuple, which is stored into the leaf TupleKeyMap +// container(s) via getOrSet (the container serializes it and records the tuple +// internally). +func (x *indexGen) genMultiCol(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, cursor, indent int, itemVar string, parts []string) { + if cursor >= len(idx.ColFields) { + x.g.P(helper.Indent(indent), "const keyParts = [", strings.Join(parts, ", "), "];") + x.emitPushAll(lm, idx, ordered, indent, "keyParts", itemVar) + return + } + field := idx.ColFields[cursor] + access, isList := x.fieldAccess(itemVar, field) + if isList { + loopVar := fmt.Sprintf("indexItem%d", cursor) + x.g.P(helper.Indent(indent), "for (const ", loopVar, " of ", access, ") {") + x.genMultiCol(lm, idx, ordered, cursor+1, indent+1, itemVar, append(append([]string{}, parts...), loopVar)) + x.g.P(helper.Indent(indent), "}") + } else { + x.genMultiCol(lm, idx, ordered, cursor+1, indent, itemVar, append(append([]string{}, parts...), access)) + } +} + +// emitPushAll appends itemVar into the global container and every leveled +// container, bucketed under keyExpr. Multi-column indexes store a value list +// per key tuple in a leaf TupleKeyMap (get-or-create the list via getOrSet, +// then push); single-column indexes push the raw key into a native Map's value +// list. Leveled containers are first scoped to the upper map key(s): a single +// upper key (i==1) indexes a native Map by its raw value, while composite upper +// keys (i>=2) get-or-create the inner container in a TupleKeyMap via getOrSet. +func (x *indexGen) emitPushAll(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool, indent int, keyExpr, itemVar string) { + multi := len(idx.ColFields) > 1 + if multi { + x.g.P(helper.Indent(indent), "this.", x.containerField(idx, ordered, 0), ".getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.emitPushInto(indent, "this."+x.containerField(idx, ordered, 0), keyExpr, itemVar) + } + for i := 1; i < lm.LeveledContainerDepth(); i++ { + container := x.containerField(idx, ordered, i) + x.g.P(helper.Indent(indent), "{") + if i == 1 { + // Single upper key: native Map keyed by the raw k1 value. + if multi { + x.g.P(helper.Indent(indent+1), "let m = this.", container, ".get(k1);") + x.g.P(helper.Indent(indent+1), "if (!m) { m = ", x.newLeafContainer(idx, ordered), "; this.", container, ".set(k1, m); }") + x.g.P(helper.Indent(indent+1), "m.getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.g.P(helper.Indent(indent+1), "let map = this.", container, ".get(k1);") + x.g.P(helper.Indent(indent+1), "if (!map) { map = new Map(); this.", container, ".set(k1, map); }") + x.emitPushInto(indent+1, "map", keyExpr, itemVar) + } + } else { + // Composite upper keys: TupleKeyMap keyed by the [k1..ki] tuple. + var ks []string + for j := 1; j <= i; j++ { + ks = append(ks, fmt.Sprintf("k%d", j)) + } + tuple := "[" + strings.Join(ks, ", ") + "]" + if multi { + x.g.P(helper.Indent(indent+1), "const m = this.", container, ".getOrSet(", tuple, ", () => ", x.newLeafContainer(idx, ordered), ");") + x.g.P(helper.Indent(indent+1), "m.getOrSet(", keyExpr, ", () => []).push(", itemVar, ");") + } else { + x.g.P(helper.Indent(indent+1), "const map = this.", container, ".getOrSet(", tuple, ", () => new Map());") + x.emitPushInto(indent+1, "map", keyExpr, itemVar) + } + } + x.g.P(helper.Indent(indent), "}") + } +} + +// emitPushInto pushes itemVar into the value list bucketed under keyExpr in the +// given map expression. +func (x *indexGen) emitPushInto(indent int, mapExpr, keyExpr, itemVar string) { + x.g.P(helper.Indent(indent), "{") + x.g.P(helper.Indent(indent+1), "const list = ", mapExpr, ".get(", keyExpr, ");") + x.g.P(helper.Indent(indent+1), "if (list) { list.push(", itemVar, "); } else { ", mapExpr, ".set(", keyExpr, ", [", itemVar, "]); }") + x.g.P(helper.Indent(indent), "}") +} + +// fieldAccess builds the TypeScript access expression for a level field rooted +// at itemVar, returning the expression and whether the (terminal) field is a +// list (which the caller iterates). +func (x *indexGen) fieldAccess(itemVar string, field *index.LevelField) (string, bool) { + expr := itemVar + needEmpty := len(field.LeveledFDList) > 1 + isTimestamp := false + for i, fd := range field.LeveledFDList { + sep := "." + if i != 0 { + sep = "?." + } + expr += sep + helper.FieldLocalName(fd) + if i == len(field.LeveledFDList)-1 && fd.Message() != nil { + switch fd.Message().FullName() { + case "google.protobuf.Timestamp", "google.protobuf.Duration": + expr += "?.seconds ?? 0n" + isTimestamp = true + needEmpty = false + } + } + } + if field.FD.IsList() { + return expr + " ?? []", true + } + if needEmpty && !isTimestamp { + expr += " ?? " + helper.TSEmptyValue(field.FD) + } + return expr, false +} + +// genValueListSorter emits a comparator for an index's sorted columns and sorts +// every value list (global and leveled) with it. +func (x *indexGen) genValueListSorter(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + if len(idx.SortedColFields) == 0 { + return + } + label := "Index" + if ordered { + label = "OrderedIndex" + } + v := x.valueType(idx) + cmp := "cmp" + idx.Name() + var aParts, bParts []string + for _, field := range idx.SortedColFields { + a, _ := x.fieldAccess("a", field) + b, _ := x.fieldAccess("b", field) + aParts = append(aParts, a) + bParts = append(bParts, b) + } + x.g.P(helper.Indent(2), "// ", label, "(sort): ", idx.Index) + x.g.P(helper.Indent(2), "const ", cmp, " = (a: ", v, ", b: ", v, "): number => compareTuples([", strings.Join(aParts, ", "), "], [", strings.Join(bParts, ", "), "]);") + x.g.P(helper.Indent(2), "for (const list of this.", x.containerField(idx, ordered, 0), ".values()) {") + x.g.P(helper.Indent(3), "list.sort(", cmp, ");") + x.g.P(helper.Indent(2), "}") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + x.g.P(helper.Indent(2), "for (const m of this.", x.containerField(idx, ordered, i), ".values()) {") + x.g.P(helper.Indent(3), "for (const list of m.values()) {") + x.g.P(helper.Indent(4), "list.sort(", cmp, ");") + x.g.P(helper.Indent(3), "}") + x.g.P(helper.Indent(2), "}") + } +} + +// genOrderedRebuild rebuilds an ordered index's global and leveled maps so they +// are sorted by key. Multi-column indexes sort the TupleKeyMap in place by its +// key tuples (sortKeys); single-column indexes re-sort the native Map by key. +func (x *indexGen) genOrderedRebuild(lm *index.LevelMessage, idx *index.LevelIndex) { + multi := len(idx.ColFields) > 1 + c0 := x.containerField(idx, true, 0) + if multi { + x.g.P(helper.Indent(2), "this.", c0, ".sortKeys();") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + ci := x.containerField(idx, true, i) + x.g.P(helper.Indent(2), "for (const m of this.", ci, ".values()) {") + x.g.P(helper.Indent(3), "m.sortKeys();") + x.g.P(helper.Indent(2), "}") + } + return + } + x.g.P(helper.Indent(2), "this.", c0, " = sortMapByKey(this.", c0, ", compareValues);") + for i := 1; i < lm.LeveledContainerDepth(); i++ { + ci := x.containerField(idx, true, i) + x.g.P(helper.Indent(2), "for (const m of this.", ci, ".values()) {") + x.g.P(helper.Indent(3), "sortMapByKey(m, compareValues);") + x.g.P(helper.Indent(2), "}") + } +} + +// ---------------------------------------------------------------------------- +// Getters / finders +// ---------------------------------------------------------------------------- + +// GenGetters emits the ordered-map getter and index/ordered-index finders. +func (x *indexGen) GenGetters() { + if x.needOrderedMap() { + x.genOrderedMapGetters(x.message.Desc, 1, nil) + } + if x.needIndex() { + x.eachIndex(false, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genFinders(lm, idx, false) + }) + } + if x.needOrderedIndex() { + x.eachIndex(true, func(lm *index.LevelMessage, idx *index.LevelIndex) { + x.genFinders(lm, idx, true) + }) + } +} + +// orderedMapGetterName returns the getter name for the depth-level ordered map: +// getOrderedMap for the 1st level, getOrderedMap1/2/... for deeper levels (the +// suffix is the number of upper keys needed to reach it), mirroring the Go / +// C++ / C# GetOrderedMap / GetOrderedMap1 naming. +func orderedMapGetterName(depth int) string { + if depth == 1 { + return "getOrderedMap" + } + return fmt.Sprintf("getOrderedMap%d", depth-1) +} + +// genOrderedMapGetters recursively emits the ordered-map getters along the +// first-map-field chain. The 1st-level getter returns the whole ordered map; +// each deeper getter takes the accumulated upper keys and descends one level by +// looking the next key up and following the node's .first sub-map, returning +// undefined if any key is absent (matching the TS finder convention). +func (x *indexGen) genOrderedMapGetters(md protoreflect.MessageDescriptor, depth int, keys helper.MapKeySlice) { + fd := firstMapField(md) + if fd == nil { + return + } + mapType := x.orderedMapTypeOf(fd) + getter := orderedMapGetterName(depth) + ordinal := loadutil.Ordinal(depth) + x.g.P() + if depth == 1 { + x.g.P(helper.Indent(1), "/** ", getter, " returns the 1st-level ordered map (sorted by key). */") + x.g.P(helper.Indent(1), getter, "(): ", mapType, " {") + x.g.P(helper.Indent(2), "return this.", orderedMapField, ";") + x.g.P(helper.Indent(1), "}") + } else { + params := keys.GenGetParams() + lastKey := keys[len(keys)-1].Name + x.g.P(helper.Indent(1), "/** ", getter, " returns the ", ordinal, "-level ordered map scoped to the given upper key(s), or undefined. */") + x.g.P(helper.Indent(1), getter, "(", params, "): ", mapType, " | undefined {") + if depth == 2 { + x.g.P(helper.Indent(2), "return this.", orderedMapField, ".get(", lastKey, ")?.first;") + } else { + prevArgs := keys[:len(keys)-1].GenGetArguments() + x.g.P(helper.Indent(2), "return this.", orderedMapGetterName(depth-1), "(", prevArgs, ")?.get(", lastKey, ")?.first;") + } + x.g.P(helper.Indent(1), "}") + } + nextKeys := keys.AddMapKey(helper.ParseMapKey(fd.MapKey(), helper.ParseMapFieldNameAsFuncParam(fd))) + if fd.MapValue().Kind() == protoreflect.MessageKind { + x.genOrderedMapGetters(fd.MapValue().Message(), depth+1, nextKeys) + } +} + +func (x *indexGen) genFinders(lm *index.LevelMessage, idx *index.LevelIndex, ordered bool) { + kind := "index" + if ordered { + kind = "ordered index" + } + name := idx.Name() + v := x.valueType(idx) + at := x.indexType(idx, ordered) + keys := x.params(idx) + params := keys.GenGetParams() + args := keys.GenGetArguments() + + // keyExpr is the key passed to a container's get(): a raw value / native-Map + // key for single-column, or a key-column tuple for the multi-column + // TupleKeyMap (which serializes it internally). + keyExpr := args + multi := len(idx.ColFields) > 1 + if multi { + keyExpr = "[" + args + "]" + } + + container0 := x.containerField(idx, ordered, 0) + + // The map finders return the global / leveled container directly: a native + // Map for single-column indexes, or the owning TupleKeyMap (which exposes + // readable tuple keys) for multi-column indexes. + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, "Map returns the ", kind, " map: key(", idx.Index, ") -> values. */") + x.g.P(helper.Indent(1), "find", name, "Map(): ", at, " {") + x.g.P(helper.Indent(2), "return this.", container0, ";") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, " returns all values for the given key(s), or undefined. */") + x.g.P(helper.Indent(1), "find", name, "(", params, "): ", v, "[] | undefined {") + x.g.P(helper.Indent(2), "return this.", container0, ".get(", keyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** findFirst", name, " returns the first value for the given key(s), or undefined. */") + x.g.P(helper.Indent(1), "findFirst", name, "(", params, "): ", v, " | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, "(", args, ")?.[0];") + x.g.P(helper.Indent(1), "}") + + for i := 1; i < lm.LeveledContainerDepth(); i++ { + container := x.containerField(idx, ordered, i) + upperKeys := x.keys[:i] + upperParams := upperKeys.GenGetParams() + upperArgs := upperKeys.GenGetArguments() + // A single upper key indexes a native Map by its raw value; composite + // upper keys index a TupleKeyMap by the upper key tuple. + upperKeyExpr := upperArgs + if i > 1 { + upperKeyExpr = "[" + upperArgs + "]" + } + ordinal := loadutil.Ordinal(i) + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, "Map", i, " returns the ", kind, " map scoped to the upper ", ordinal, "-level map key(s). */") + x.g.P(helper.Indent(1), "find", name, "Map", i, "(", upperParams, "): ", at, " | undefined {") + x.g.P(helper.Indent(2), "return this.", container, ".get(", upperKeyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** find", name, i, " returns all values for the given key(s) within the upper ", ordinal, "-level map. */") + x.g.P(helper.Indent(1), "find", name, i, "(", upperParams, ", ", params, "): ", v, "[] | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, "Map", i, "(", upperArgs, ")?.get(", keyExpr, ");") + x.g.P(helper.Indent(1), "}") + + x.g.P() + x.g.P(helper.Indent(1), "/** findFirst", name, i, " returns the first value for the given key(s) within the upper ", ordinal, "-level map. */") + x.g.P(helper.Indent(1), "findFirst", name, i, "(", upperParams, ", ", params, "): ", v, " | undefined {") + x.g.P(helper.Indent(2), "return this.find", name, i, "(", upperArgs, ", ", args, ")?.[0];") + x.g.P(helper.Indent(1), "}") + } +} + +// collectIndexPackages registers the owning packages of every enum/message +// referenced by an index key or value, across all levels, so their barrel +// imports are emitted. +func collectIndexPackages(descriptor *index.IndexDescriptor, pkgs *pbPackages) { + addFields := func(idx *index.LevelIndex) { + pkgs.add(string(idx.MD.ParentFile().Package())) + fields := append(append([]*index.LevelField{}, idx.ColFields...), idx.SortedColFields...) + for _, field := range fields { + // Only enum key fields reference a generated named type. Message + // key fields are exclusively Timestamp/Duration, which are encoded + // as bigint (via .seconds) and never reference the well-known type + // module (protobuf-es serves those from @bufbuild/protobuf/wkt, not + // a generated google/protobuf/*_pb module), so they add no package. + if field.FD.Kind() == protoreflect.EnumKind { + pkgs.add(string(field.FD.Enum().ParentFile().Package())) + } + } + } + for lm := descriptor.LevelMessage; lm != nil; lm = lm.NextLevel { + for _, idx := range lm.Indexes { + addFields(idx) + } + for _, idx := range lm.OrderedIndexes { + addFields(idx) + } + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/messager.go b/cmd/protoc-gen-ts-tableau-loader/messager.go index c42aed5..f633fd1 100644 --- a/cmd/protoc-gen-ts-tableau-loader/messager.go +++ b/cmd/protoc-gen-ts-tableau-loader/messager.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/index" "github.com/tableauio/loader/internal/loadutil" "github.com/tableauio/tableau/proto/tableaupb" "google.golang.org/protobuf/compiler/protogen" @@ -35,10 +36,39 @@ func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegi } } - // Runtime imports. + // Parse index descriptors up-front: they drive both the extra package + // imports (enum/message index-key types) and whether the index runtime + // helpers need importing. + descriptors := make(map[*protogen.Message]*index.IndexDescriptor, len(messagers)) + needIndexRuntime := false + needOrderedMapValue := false + for _, message := range messagers { + desc := index.ParseIndexDescriptor(message.Desc) + descriptors[message] = desc + ig := newIndexGen(g, desc, message, nil) + if ig.NeedGenerate() { + needIndexRuntime = true + } + if ig.needNestedOrderedMap() { + needOrderedMapValue = true + } + } + + // Runtime imports. The index container runtime (TupleKeyMap & comparators) + // lives alongside Format in util.pc.ts, so it is pulled from the same module + // and only when this file uses an index / ordered-index / ordered-map + // container. g.P(`import { create } from "@bufbuild/protobuf";`) g.P(`import { Messager } from "./messager.pc.js";`) - g.P(`import { Format } from "./util.pc.js";`) + if needIndexRuntime { + if needOrderedMapValue { + g.P(`import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap, type OrderedMapValue } from "./util.pc.js";`) + } else { + g.P(`import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js";`) + } + } else { + g.P(`import { Format } from "./util.pc.js";`) + } g.P(`import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js";`) // Namespace imports for the protobuf-es generated types, one per proto // package: the worksheet's own package plus any other package owning a @@ -52,6 +82,7 @@ func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegi pkgs.add(string(file.Desc.Package())) for _, message := range messagers { collectValuePackages(message.Desc, pkgs) + collectIndexPackages(descriptors[message], pkgs) } pkgs.emit(g) for _, pkg := range pkgs.order { @@ -63,23 +94,25 @@ func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegi if i > 0 { g.P() } - genMessage(gen, g, message, pkgs) + genMessage(gen, g, message, descriptors[message], pkgs) } } // genMessage generates a single messager class definition. -func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protogen.Message, pkgs *pbPackages) { +func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protogen.Message, descriptor *index.IndexDescriptor, pkgs *pbPackages) { md := message.Desc name := helper.MessagerName(md) alias := pkgs.aliasOf(md) schema := alias + "." + helper.LocalSchemaName(md) // runtime schema value dataType := alias + "." + helper.LocalTypeName(md) // message type + idxGen := newIndexGen(g, descriptor, message, pkgs) g.P("/**") g.P(" * ", name, " is a wrapper around protobuf message ", md.FullName(), ".") g.P(" */") g.P("export class ", name, " extends Messager {") - g.P(helper.Indent(1), "private data_: ", dataType, " = create(", schema, ");") + g.P(helper.Indent(1), "#data: ", dataType, " = create(", schema, ");") + idxGen.GenDecls() g.P() // name() @@ -94,7 +127,7 @@ func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protog g.P(helper.Indent(1), "load(dir: string, fmt: Format, options?: MessagerOptions): void {") g.P(helper.Indent(2), "const start = Date.now();") g.P(helper.Indent(2), "try {") - g.P(helper.Indent(3), "this.data_ = loadMessagerInDir(", schema, ", dir, fmt, options);") + g.P(helper.Indent(3), "this.#data = loadMessagerInDir(", schema, ", dir, fmt, options);") g.P(helper.Indent(2), "} catch (e) {") g.P(helper.Indent(3), "throw new Error(`failed to load ", name, "`, { cause: e });") g.P(helper.Indent(2), "}") @@ -106,20 +139,36 @@ func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protog // data() g.P(helper.Indent(1), "/** data returns the ", name, "'s inner message data. */") g.P(helper.Indent(1), "data(): ", dataType, " {") - g.P(helper.Indent(2), "return this.data_;") + g.P(helper.Indent(2), "return this.#data;") g.P(helper.Indent(1), "}") g.P() // message() g.P(helper.Indent(1), "/** message returns the ", name, "'s inner message data. */") g.P(helper.Indent(1), "override message(): ", dataType, " {") - g.P(helper.Indent(2), "return this.data_;") + g.P(helper.Indent(2), "return this.#data;") g.P(helper.Indent(1), "}") + // processAfterLoad() override: build index / ordered index / ordered map. + if idxGen.NeedGenerate() { + g.P() + g.P(helper.Indent(1), "/** processAfterLoad builds the index, ordered index and ordered map containers. */") + g.P(helper.Indent(1), "override processAfterLoad(): void {") + idxGen.GenProcessAfterLoadBody() + g.P(helper.Indent(1), "}") + } + // syntactic sugar for accessing map items genMapGetters(g, md, 1, nil, pkgs) + // index / ordered index finders and the ordered map getter + idxGen.GenGetters() + g.P("}") + + // Type aliases for the index / ordered index / ordered map containers, + // declared in a namespace merged with the class above. + idxGen.GenTypeAliases() } // genMapGetters generates nested map getters (get1/get2/...) for a message. @@ -142,7 +191,7 @@ func genMapGetters(g *protogen.GeneratedFile, md protoreflect.MessageDescriptor, var access string if depth == 1 { - access = "this.data_." + localName + "[" + last.IndexExpr() + "]" + access = "this.#data." + localName + "[" + last.IndexExpr() + "]" } else { prevArgs := keys[:len(keys)-1].GenGetArguments() access = fmt.Sprintf("this.get%d(%s)?.%s[%s]", depth-1, prevArgs, localName, last.IndexExpr()) diff --git a/test/ts-tableau-loader/tableau/hero_conf.pc.ts b/test/ts-tableau-loader/tableau/hero_conf.pc.ts index 4e8a5c7..10c0c40 100644 --- a/test/ts-tableau-loader/tableau/hero_conf.pc.ts +++ b/test/ts-tableau-loader/tableau/hero_conf.pc.ts @@ -16,7 +16,7 @@ import * as base from "./barrel/base.pc.js"; * HeroConf is a wrapper around protobuf message protoconf.HeroConf. */ export class HeroConf extends Messager { - private data_: protoconf.HeroConf = create(protoconf.HeroConfSchema); + #data: protoconf.HeroConf = create(protoconf.HeroConfSchema); /** name returns the HeroConf's message name. */ name(): string { @@ -27,7 +27,7 @@ export class HeroConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.HeroConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.HeroConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load HeroConf`, { cause: e }); } @@ -37,17 +37,17 @@ export class HeroConf extends Messager { /** data returns the HeroConf's inner message data. */ data(): protoconf.HeroConf { - return this.data_; + return this.#data; } /** message returns the HeroConf's inner message data. */ override message(): protoconf.HeroConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(name: string): protoconf.HeroConf_Hero | undefined { - return this.data_.heroMap[name]; + return this.#data.heroMap[name]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ @@ -60,7 +60,7 @@ export class HeroConf extends Messager { * HeroBaseConf is a wrapper around protobuf message protoconf.HeroBaseConf. */ export class HeroBaseConf extends Messager { - private data_: protoconf.HeroBaseConf = create(protoconf.HeroBaseConfSchema); + #data: protoconf.HeroBaseConf = create(protoconf.HeroBaseConfSchema); /** name returns the HeroBaseConf's message name. */ name(): string { @@ -71,7 +71,7 @@ export class HeroBaseConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.HeroBaseConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.HeroBaseConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load HeroBaseConf`, { cause: e }); } @@ -81,17 +81,17 @@ export class HeroBaseConf extends Messager { /** data returns the HeroBaseConf's inner message data. */ data(): protoconf.HeroBaseConf { - return this.data_; + return this.#data; } /** message returns the HeroBaseConf's inner message data. */ override message(): protoconf.HeroBaseConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(name: string): base.Hero | undefined { - return this.data_.heroMap[name]; + return this.#data.heroMap[name]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ diff --git a/test/ts-tableau-loader/tableau/index_conf.pc.ts b/test/ts-tableau-loader/tableau/index_conf.pc.ts index 952c5fa..367eee6 100644 --- a/test/ts-tableau-loader/tableau/index_conf.pc.ts +++ b/test/ts-tableau-loader/tableau/index_conf.pc.ts @@ -7,7 +7,7 @@ import { create } from "@bufbuild/protobuf"; import { Messager } from "./messager.pc.js"; -import { Format } from "./util.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js"; import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; import * as protoconf from "./barrel/protoconf.pc.js"; @@ -15,7 +15,11 @@ import * as protoconf from "./barrel/protoconf.pc.js"; * FruitConf is a wrapper around protobuf message protoconf.FruitConf. */ export class FruitConf extends Messager { - private data_: protoconf.FruitConf = create(protoconf.FruitConfSchema); + #data: protoconf.FruitConf = create(protoconf.FruitConfSchema); + #indexItemMap: FruitConf.Index_ItemMap = new Map(); + #indexItemMap1: Map = new Map(); + #orderedIndexOrderedFruitMap: FruitConf.OrderedIndex_OrderedFruitMap = new Map(); + #orderedIndexOrderedFruitMap1: Map = new Map(); /** name returns the FruitConf's message name. */ name(): string { @@ -26,7 +30,7 @@ export class FruitConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.FruitConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.FruitConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load FruitConf`, { cause: e }); } @@ -36,30 +40,180 @@ export class FruitConf extends Messager { /** data returns the FruitConf's inner message data. */ data(): protoconf.FruitConf { - return this.data_; + return this.#data; } /** message returns the FruitConf's inner message data. */ override message(): protoconf.FruitConf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexItemMap.clear(); + this.#indexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.itemMap)) { + { + // Index: Price + const key = item2.price; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item2); } else { this.#indexItemMap.set(key, [item2]); } + } + { + let map = this.#indexItemMap1.get(k1); + if (!map) { map = new Map(); this.#indexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // Index(sort): Price + const cmpItem = (a: protoconf.FruitConf_Fruit_Item, b: protoconf.FruitConf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#indexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + // OrderedIndex init. + this.#orderedIndexOrderedFruitMap.clear(); + this.#orderedIndexOrderedFruitMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.itemMap)) { + { + // OrderedIndex: Price@OrderedFruit + const key = item2.price; + { + const list = this.#orderedIndexOrderedFruitMap.get(key); + if (list) { list.push(item2); } else { this.#orderedIndexOrderedFruitMap.set(key, [item2]); } + } + { + let map = this.#orderedIndexOrderedFruitMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexOrderedFruitMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // OrderedIndex(sort): Price@OrderedFruit + const cmpOrderedFruit = (a: protoconf.FruitConf_Fruit_Item, b: protoconf.FruitConf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedFruitMap.values()) { + list.sort(cmpOrderedFruit); + } + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + for (const list of m.values()) { + list.sort(cmpOrderedFruit); + } + } + this.#orderedIndexOrderedFruitMap = sortMapByKey(this.#orderedIndexOrderedFruitMap, compareValues); + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + sortMapByKey(m, compareValues); + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(fruitType: number): protoconf.FruitConf_Fruit | undefined { - return this.data_.fruitMap[fruitType]; + return this.#data.fruitMap[fruitType]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ get2(fruitType: number, id: number): protoconf.FruitConf_Fruit_Item | undefined { return this.get1(fruitType)?.itemMap[id]; } + + /** findItemMap returns the index map: key(Price) -> values. */ + findItemMap(): FruitConf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.#indexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): FruitConf.Index_ItemMap | undefined { + return this.#indexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findOrderedFruitMap returns the ordered index map: key(Price@OrderedFruit) -> values. */ + findOrderedFruitMap(): FruitConf.OrderedIndex_OrderedFruitMap { + return this.#orderedIndexOrderedFruitMap; + } + + /** findOrderedFruit returns all values for the given key(s), or undefined. */ + findOrderedFruit(price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.#orderedIndexOrderedFruitMap.get(price); + } + + /** findFirstOrderedFruit returns the first value for the given key(s), or undefined. */ + findFirstOrderedFruit(price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findOrderedFruit(price)?.[0]; + } + + /** findOrderedFruitMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findOrderedFruitMap1(fruitType: number): FruitConf.OrderedIndex_OrderedFruitMap | undefined { + return this.#orderedIndexOrderedFruitMap1.get(fruitType); + } + + /** findOrderedFruit1 returns all values for the given key(s) within the upper 1st-level map. */ + findOrderedFruit1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item[] | undefined { + return this.findOrderedFruitMap1(fruitType)?.get(price); + } + + /** findFirstOrderedFruit1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstOrderedFruit1(fruitType: number, price: number): protoconf.FruitConf_Fruit_Item | undefined { + return this.findOrderedFruit1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. FruitConf.Index_XxxMap). +export namespace FruitConf { + /** Index_ItemMap is the index map: key(Price) -> values. */ + export type Index_ItemMap = Map; + /** OrderedIndex_OrderedFruitMap is the ordered index map: key(Price@OrderedFruit) -> values. */ + export type OrderedIndex_OrderedFruitMap = Map; } /** * Fruit6Conf is a wrapper around protobuf message protoconf.Fruit6Conf. */ export class Fruit6Conf extends Messager { - private data_: protoconf.Fruit6Conf = create(protoconf.Fruit6ConfSchema); + #data: protoconf.Fruit6Conf = create(protoconf.Fruit6ConfSchema); + #indexItemMap: Fruit6Conf.Index_ItemMap = new Map(); + #indexItemMap1: Map = new Map(); + #orderedIndexOrderedFruitMap: Fruit6Conf.OrderedIndex_OrderedFruitMap = new Map(); + #orderedIndexOrderedFruitMap1: Map = new Map(); /** name returns the Fruit6Conf's message name. */ name(): string { @@ -70,7 +224,7 @@ export class Fruit6Conf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.Fruit6ConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.Fruit6ConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load Fruit6Conf`, { cause: e }); } @@ -80,25 +234,178 @@ export class Fruit6Conf extends Messager { /** data returns the Fruit6Conf's inner message data. */ data(): protoconf.Fruit6Conf { - return this.data_; + return this.#data; } /** message returns the Fruit6Conf's inner message data. */ override message(): protoconf.Fruit6Conf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexItemMap.clear(); + this.#indexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.itemList ?? []) { + { + // Index: Price + const key = item2.price; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item2); } else { this.#indexItemMap.set(key, [item2]); } + } + { + let map = this.#indexItemMap1.get(k1); + if (!map) { map = new Map(); this.#indexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // Index(sort): Price + const cmpItem = (a: protoconf.Fruit6Conf_Fruit_Item, b: protoconf.Fruit6Conf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#indexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + // OrderedIndex init. + this.#orderedIndexOrderedFruitMap.clear(); + this.#orderedIndexOrderedFruitMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.itemList ?? []) { + { + // OrderedIndex: Price@OrderedFruit + const key = item2.price; + { + const list = this.#orderedIndexOrderedFruitMap.get(key); + if (list) { list.push(item2); } else { this.#orderedIndexOrderedFruitMap.set(key, [item2]); } + } + { + let map = this.#orderedIndexOrderedFruitMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexOrderedFruitMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } + // OrderedIndex(sort): Price@OrderedFruit + const cmpOrderedFruit = (a: protoconf.Fruit6Conf_Fruit_Item, b: protoconf.Fruit6Conf_Fruit_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedFruitMap.values()) { + list.sort(cmpOrderedFruit); + } + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + for (const list of m.values()) { + list.sort(cmpOrderedFruit); + } + } + this.#orderedIndexOrderedFruitMap = sortMapByKey(this.#orderedIndexOrderedFruitMap, compareValues); + for (const m of this.#orderedIndexOrderedFruitMap1.values()) { + sortMapByKey(m, compareValues); + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(fruitType: number): protoconf.Fruit6Conf_Fruit | undefined { - return this.data_.fruitMap[fruitType]; + return this.#data.fruitMap[fruitType]; } + + /** findItemMap returns the index map: key(Price) -> values. */ + findItemMap(): Fruit6Conf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.#indexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit6Conf.Index_ItemMap | undefined { + return this.#indexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findOrderedFruitMap returns the ordered index map: key(Price@OrderedFruit) -> values. */ + findOrderedFruitMap(): Fruit6Conf.OrderedIndex_OrderedFruitMap { + return this.#orderedIndexOrderedFruitMap; + } + + /** findOrderedFruit returns all values for the given key(s), or undefined. */ + findOrderedFruit(price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.#orderedIndexOrderedFruitMap.get(price); + } + + /** findFirstOrderedFruit returns the first value for the given key(s), or undefined. */ + findFirstOrderedFruit(price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findOrderedFruit(price)?.[0]; + } + + /** findOrderedFruitMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findOrderedFruitMap1(fruitType: number): Fruit6Conf.OrderedIndex_OrderedFruitMap | undefined { + return this.#orderedIndexOrderedFruitMap1.get(fruitType); + } + + /** findOrderedFruit1 returns all values for the given key(s) within the upper 1st-level map. */ + findOrderedFruit1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item[] | undefined { + return this.findOrderedFruitMap1(fruitType)?.get(price); + } + + /** findFirstOrderedFruit1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstOrderedFruit1(fruitType: number, price: number): protoconf.Fruit6Conf_Fruit_Item | undefined { + return this.findOrderedFruit1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit6Conf.Index_XxxMap). +export namespace Fruit6Conf { + /** Index_ItemMap is the index map: key(Price) -> values. */ + export type Index_ItemMap = Map; + /** OrderedIndex_OrderedFruitMap is the ordered index map: key(Price@OrderedFruit) -> values. */ + export type OrderedIndex_OrderedFruitMap = Map; } /** * Fruit2Conf is a wrapper around protobuf message protoconf.Fruit2Conf. */ export class Fruit2Conf extends Messager { - private data_: protoconf.Fruit2Conf = create(protoconf.Fruit2ConfSchema); + #data: protoconf.Fruit2Conf = create(protoconf.Fruit2ConfSchema); + #indexCountryMap: Fruit2Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); + #indexAttrMap: Fruit2Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #indexAttrMap2: TupleKeyMap = new TupleKeyMap(); + #orderedIndexItemMap: Fruit2Conf.OrderedIndex_ItemMap = new Map(); + #orderedIndexItemMap1: Map = new Map(); /** name returns the Fruit2Conf's message name. */ name(): string { @@ -109,7 +416,7 @@ export class Fruit2Conf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.Fruit2ConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.Fruit2ConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load Fruit2Conf`, { cause: e }); } @@ -119,25 +426,247 @@ export class Fruit2Conf extends Messager { /** data returns the Fruit2Conf's inner message data. */ data(): protoconf.Fruit2Conf { - return this.data_; + return this.#data; } /** message returns the Fruit2Conf's inner message data. */ override message(): protoconf.Fruit2Conf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + this.#indexAttrMap2.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.countryList ?? []) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k2Str, item3] of Object.entries(item2.itemMap)) { + const k2 = Number(k2Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + this.#orderedIndexItemMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of item1.countryList ?? []) { + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + { + let map = this.#orderedIndexItemMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit2Conf_Fruit_Country_Item, b: protoconf.Fruit2Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#orderedIndexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + for (const m of this.#orderedIndexItemMap1.values()) { + sortMapByKey(m, compareValues); + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(fruitType: number): protoconf.Fruit2Conf_Fruit | undefined { - return this.data_.fruitMap[fruitType]; + return this.#data.fruitMap[fruitType]; + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit2Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit2Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit2Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit2Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit2Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(fruitType: number): Fruit2Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(fruitType); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(fruitType)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(fruitType: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(fruitType, name)?.[0]; + } + + /** findAttrMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAttrMap2(fruitType: number, id: number): Fruit2Conf.Index_AttrMap | undefined { + return this.#indexAttrMap2.get([fruitType, id]); + } + + /** findAttr2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAttr2(fruitType: number, id: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap2(fruitType, id)?.get(name); + } + + /** findFirstAttr2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAttr2(fruitType: number, id: number, name: string): protoconf.Fruit2Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr2(fruitType, id, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit2Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit2Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit2Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit2Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit2Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit2Conf_Fruit_Country_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit2Conf.Index_XxxMap). +export namespace Fruit2Conf { + /** LevelIndex_Fruit_Country_ItemKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Fruit_Country_ItemKey = readonly [fruitType: number, id: number]; + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; } /** * Fruit3Conf is a wrapper around protobuf message protoconf.Fruit3Conf. */ export class Fruit3Conf extends Messager { - private data_: protoconf.Fruit3Conf = create(protoconf.Fruit3ConfSchema); + #data: protoconf.Fruit3Conf = create(protoconf.Fruit3ConfSchema); + #indexCountryMap: Fruit3Conf.Index_CountryMap = new Map(); + #indexAttrMap: Fruit3Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #orderedIndexItemMap: Fruit3Conf.OrderedIndex_ItemMap = new Map(); /** name returns the Fruit3Conf's message name. */ name(): string { @@ -148,7 +677,7 @@ export class Fruit3Conf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.Fruit3ConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.Fruit3ConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load Fruit3Conf`, { cause: e }); } @@ -158,20 +687,164 @@ export class Fruit3Conf extends Messager { /** data returns the Fruit3Conf's inner message data. */ data(): protoconf.Fruit3Conf { - return this.data_; + return this.#data; } /** message returns the Fruit3Conf's inner message data. */ override message(): protoconf.Fruit3Conf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + for (const item1 of this.#data.fruitList ?? []) { + for (const item2 of item1.countryList ?? []) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + } + for (const [k1Str, item3] of Object.entries(item2.itemMap)) { + const k1 = Number(k1Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + for (const item1 of this.#data.fruitList ?? []) { + for (const item2 of item1.countryList ?? []) { + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit3Conf_Fruit_Country_Item, b: protoconf.Fruit3Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit3Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit3Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit3Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit3Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(id: number): Fruit3Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(id); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(id: number, name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(id)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(id: number, name: string): protoconf.Fruit3Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(id, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit3Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit3Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit3Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit3Conf.Index_XxxMap). +export namespace Fruit3Conf { + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; } /** * Fruit4Conf is a wrapper around protobuf message protoconf.Fruit4Conf. */ export class Fruit4Conf extends Messager { - private data_: protoconf.Fruit4Conf = create(protoconf.Fruit4ConfSchema); + #data: protoconf.Fruit4Conf = create(protoconf.Fruit4ConfSchema); + #indexCountryMap: Fruit4Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); + #indexAttrMap: Fruit4Conf.Index_AttrMap = new Map(); + #indexAttrMap1: Map = new Map(); + #indexAttrMap2: TupleKeyMap = new TupleKeyMap(); + #indexAttrMap3: TupleKeyMap = new TupleKeyMap(); + #orderedIndexItemMap: Fruit4Conf.OrderedIndex_ItemMap = new Map(); + #orderedIndexItemMap1: Map = new Map(); + #orderedIndexItemMap2: TupleKeyMap = new TupleKeyMap(); /** name returns the Fruit4Conf's message name. */ name(): string { @@ -182,7 +855,7 @@ export class Fruit4Conf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.Fruit4ConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.Fruit4ConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load Fruit4Conf`, { cause: e }); } @@ -192,17 +865,142 @@ export class Fruit4Conf extends Messager { /** data returns the Fruit4Conf's inner message data. */ data(): protoconf.Fruit4Conf { - return this.data_; + return this.#data; } /** message returns the Fruit4Conf's inner message data. */ override message(): protoconf.Fruit4Conf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + this.#indexAttrMap.clear(); + this.#indexAttrMap1.clear(); + this.#indexAttrMap2.clear(); + this.#indexAttrMap3.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const [k2Str, item2] of Object.entries(item1.countryMap)) { + const k2 = Number(k2Str); + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k3Str, item3] of Object.entries(item2.itemMap)) { + const k3 = Number(k3Str); + for (const item4 of item3.attrList ?? []) { + { + // Index: CountryItemAttrName + const key = item4.name; + { + const list = this.#indexAttrMap.get(key); + if (list) { list.push(item4); } else { this.#indexAttrMap.set(key, [item4]); } + } + { + let map = this.#indexAttrMap1.get(k1); + if (!map) { map = new Map(); this.#indexAttrMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAttrMap3.getOrSet([k1, k2, k3], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // OrderedIndex init. + this.#orderedIndexItemMap.clear(); + this.#orderedIndexItemMap1.clear(); + this.#orderedIndexItemMap2.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const [k2Str, item2] of Object.entries(item1.countryMap)) { + const k2 = Number(k2Str); + for (const item3 of Object.values(item2.itemMap)) { + { + // OrderedIndex: CountryItemPrice + const key = item3.price; + { + const list = this.#orderedIndexItemMap.get(key); + if (list) { list.push(item3); } else { this.#orderedIndexItemMap.set(key, [item3]); } + } + { + let map = this.#orderedIndexItemMap1.get(k1); + if (!map) { map = new Map(); this.#orderedIndexItemMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + { + const map = this.#orderedIndexItemMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item3); } else { map.set(key, [item3]); } + } + } + } + } + } + } + // OrderedIndex(sort): CountryItemPrice + const cmpItem = (a: protoconf.Fruit4Conf_Fruit_Country_Item, b: protoconf.Fruit4Conf_Fruit_Country_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexItemMap.values()) { + list.sort(cmpItem); + } + for (const m of this.#orderedIndexItemMap1.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + for (const m of this.#orderedIndexItemMap2.values()) { + for (const list of m.values()) { + list.sort(cmpItem); + } + } + this.#orderedIndexItemMap = sortMapByKey(this.#orderedIndexItemMap, compareValues); + for (const m of this.#orderedIndexItemMap1.values()) { + sortMapByKey(m, compareValues); + } + for (const m of this.#orderedIndexItemMap2.values()) { + sortMapByKey(m, compareValues); + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(fruitType: number): protoconf.Fruit4Conf_Fruit | undefined { - return this.data_.fruitMap[fruitType]; + return this.#data.fruitMap[fruitType]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ @@ -214,13 +1012,166 @@ export class Fruit4Conf extends Messager { get3(fruitType: number, id: number, id3: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { return this.get2(fruitType, id)?.itemMap[id3]; } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit4Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit4Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit4Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } + + /** findAttrMap returns the index map: key(CountryItemAttrName) -> values. */ + findAttrMap(): Fruit4Conf.Index_AttrMap { + return this.#indexAttrMap; + } + + /** findAttr returns all values for the given key(s), or undefined. */ + findAttr(name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.#indexAttrMap.get(name); + } + + /** findFirstAttr returns the first value for the given key(s), or undefined. */ + findFirstAttr(name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr(name)?.[0]; + } + + /** findAttrMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAttrMap1(fruitType: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap1.get(fruitType); + } + + /** findAttr1 returns all values for the given key(s) within the upper 1st-level map. */ + findAttr1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap1(fruitType)?.get(name); + } + + /** findFirstAttr1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAttr1(fruitType: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr1(fruitType, name)?.[0]; + } + + /** findAttrMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAttrMap2(fruitType: number, id: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap2.get([fruitType, id]); + } + + /** findAttr2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAttr2(fruitType: number, id: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap2(fruitType, id)?.get(name); + } + + /** findFirstAttr2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAttr2(fruitType: number, id: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr2(fruitType, id, name)?.[0]; + } + + /** findAttrMap3 returns the index map scoped to the upper 3rd-level map key(s). */ + findAttrMap3(fruitType: number, id: number, id3: number): Fruit4Conf.Index_AttrMap | undefined { + return this.#indexAttrMap3.get([fruitType, id, id3]); + } + + /** findAttr3 returns all values for the given key(s) within the upper 3rd-level map. */ + findAttr3(fruitType: number, id: number, id3: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr[] | undefined { + return this.findAttrMap3(fruitType, id, id3)?.get(name); + } + + /** findFirstAttr3 returns the first value for the given key(s) within the upper 3rd-level map. */ + findFirstAttr3(fruitType: number, id: number, id3: number, name: string): protoconf.Fruit4Conf_Fruit_Country_Item_Attr | undefined { + return this.findAttr3(fruitType, id, id3, name)?.[0]; + } + + /** findItemMap returns the ordered index map: key(CountryItemPrice) -> values. */ + findItemMap(): Fruit4Conf.OrderedIndex_ItemMap { + return this.#orderedIndexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.#orderedIndexItemMap.get(price); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem(price)?.[0]; + } + + /** findItemMap1 returns the ordered index map scoped to the upper 1st-level map key(s). */ + findItemMap1(fruitType: number): Fruit4Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap1.get(fruitType); + } + + /** findItem1 returns all values for the given key(s) within the upper 1st-level map. */ + findItem1(fruitType: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap1(fruitType)?.get(price); + } + + /** findFirstItem1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstItem1(fruitType: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem1(fruitType, price)?.[0]; + } + + /** findItemMap2 returns the ordered index map scoped to the upper 2nd-level map key(s). */ + findItemMap2(fruitType: number, id: number): Fruit4Conf.OrderedIndex_ItemMap | undefined { + return this.#orderedIndexItemMap2.get([fruitType, id]); + } + + /** findItem2 returns all values for the given key(s) within the upper 2nd-level map. */ + findItem2(fruitType: number, id: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item[] | undefined { + return this.findItemMap2(fruitType, id)?.get(price); + } + + /** findFirstItem2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstItem2(fruitType: number, id: number, price: number): protoconf.Fruit4Conf_Fruit_Country_Item | undefined { + return this.findItem2(fruitType, id, price)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit4Conf.Index_XxxMap). +export namespace Fruit4Conf { + /** LevelIndex_Fruit_CountryKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Fruit_CountryKey = readonly [fruitType: number, id: number]; + /** LevelIndex_Fruit_Country_ItemKey is the composite upper map key (k1..k3) of the 3rd-level leveled containers. */ + export type LevelIndex_Fruit_Country_ItemKey = readonly [fruitType: number, id: number, id3: number]; + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; + /** Index_AttrMap is the index map: key(CountryItemAttrName) -> values. */ + export type Index_AttrMap = Map; + /** OrderedIndex_ItemMap is the ordered index map: key(CountryItemPrice) -> values. */ + export type OrderedIndex_ItemMap = Map; } /** * Fruit5Conf is a wrapper around protobuf message protoconf.Fruit5Conf. */ export class Fruit5Conf extends Messager { - private data_: protoconf.Fruit5Conf = create(protoconf.Fruit5ConfSchema); + #data: protoconf.Fruit5Conf = create(protoconf.Fruit5ConfSchema); + #indexCountryMap: Fruit5Conf.Index_CountryMap = new Map(); + #indexCountryMap1: Map = new Map(); /** name returns the Fruit5Conf's message name. */ name(): string { @@ -231,7 +1182,7 @@ export class Fruit5Conf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.Fruit5ConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.Fruit5ConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load Fruit5Conf`, { cause: e }); } @@ -241,17 +1192,45 @@ export class Fruit5Conf extends Messager { /** data returns the Fruit5Conf's inner message data. */ data(): protoconf.Fruit5Conf { - return this.data_; + return this.#data; } /** message returns the Fruit5Conf's inner message data. */ override message(): protoconf.Fruit5Conf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexCountryMap.clear(); + this.#indexCountryMap1.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.fruitMap)) { + const k1 = Number(k1Str); + for (const item2 of Object.values(item1.countryMap)) { + { + // Index: CountryName + const key = item2.name; + { + const list = this.#indexCountryMap.get(key); + if (list) { list.push(item2); } else { this.#indexCountryMap.set(key, [item2]); } + } + { + let map = this.#indexCountryMap1.get(k1); + if (!map) { map = new Map(); this.#indexCountryMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + } + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(fruitType: number): protoconf.Fruit5Conf_Fruit | undefined { - return this.data_.fruitMap[fruitType]; + return this.#data.fruitMap[fruitType]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ @@ -263,4 +1242,42 @@ export class Fruit5Conf extends Messager { get3(fruitType: number, id: number, id3: number): protoconf.Fruit5Conf_Fruit_Country_Item | undefined { return this.get2(fruitType, id)?.itemMap[id3]; } + + /** findCountryMap returns the index map: key(CountryName) -> values. */ + findCountryMap(): Fruit5Conf.Index_CountryMap { + return this.#indexCountryMap; + } + + /** findCountry returns all values for the given key(s), or undefined. */ + findCountry(name: string): protoconf.Fruit5Conf_Fruit_Country[] | undefined { + return this.#indexCountryMap.get(name); + } + + /** findFirstCountry returns the first value for the given key(s), or undefined. */ + findFirstCountry(name: string): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.findCountry(name)?.[0]; + } + + /** findCountryMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findCountryMap1(fruitType: number): Fruit5Conf.Index_CountryMap | undefined { + return this.#indexCountryMap1.get(fruitType); + } + + /** findCountry1 returns all values for the given key(s) within the upper 1st-level map. */ + findCountry1(fruitType: number, name: string): protoconf.Fruit5Conf_Fruit_Country[] | undefined { + return this.findCountryMap1(fruitType)?.get(name); + } + + /** findFirstCountry1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstCountry1(fruitType: number, name: string): protoconf.Fruit5Conf_Fruit_Country | undefined { + return this.findCountry1(fruitType, name)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. Fruit5Conf.Index_XxxMap). +export namespace Fruit5Conf { + /** Index_CountryMap is the index map: key(CountryName) -> values. */ + export type Index_CountryMap = Map; } diff --git a/test/ts-tableau-loader/tableau/item_conf.pc.ts b/test/ts-tableau-loader/tableau/item_conf.pc.ts index 87cdf1b..47cec32 100644 --- a/test/ts-tableau-loader/tableau/item_conf.pc.ts +++ b/test/ts-tableau-loader/tableau/item_conf.pc.ts @@ -7,7 +7,7 @@ import { create } from "@bufbuild/protobuf"; import { Messager } from "./messager.pc.js"; -import { Format } from "./util.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap } from "./util.pc.js"; import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; import * as protoconf from "./barrel/protoconf.pc.js"; @@ -15,7 +15,20 @@ import * as protoconf from "./barrel/protoconf.pc.js"; * ItemConf is a wrapper around protobuf message protoconf.ItemConf. */ export class ItemConf extends Messager { - private data_: protoconf.ItemConf = create(protoconf.ItemConfSchema); + #data: protoconf.ItemConf = create(protoconf.ItemConfSchema); + #orderedMap: ItemConf.OrderedMap_ItemMap = new Map(); + #indexItemMap: ItemConf.Index_ItemMap = new Map(); + #indexItemInfoMap: ItemConf.Index_ItemInfoMap = new Map(); + #indexItemDefaultInfoMap: ItemConf.Index_ItemDefaultInfoMap = new Map(); + #indexItemExtInfoMap: ItemConf.Index_ItemExtInfoMap = new Map(); + #indexAwardItemMap: ItemConf.Index_AwardItemMap = new TupleKeyMap(); + #indexSpecialItemMap: ItemConf.Index_SpecialItemMap = new TupleKeyMap(); + #indexItemPathDirMap: ItemConf.Index_ItemPathDirMap = new Map(); + #indexItemPathNameMap: ItemConf.Index_ItemPathNameMap = new Map(); + #indexItemPathFriendIDMap: ItemConf.Index_ItemPathFriendIDMap = new Map(); + #indexUseEffectTypeMap: ItemConf.Index_UseEffectTypeMap = new Map(); + #orderedIndexExtTypeMap: ItemConf.OrderedIndex_ExtTypeMap = new Map(); + #orderedIndexParamExtTypeMap: ItemConf.OrderedIndex_ParamExtTypeMap = new TupleKeyMap(); /** name returns the ItemConf's message name. */ name(): string { @@ -26,7 +39,7 @@ export class ItemConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.ItemConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.ItemConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load ItemConf`, { cause: e }); } @@ -36,16 +49,389 @@ export class ItemConf extends Messager { /** data returns the ItemConf's inner message data. */ data(): protoconf.ItemConf { - return this.data_; + return this.#data; } /** message returns the ItemConf's inner message data. */ override message(): protoconf.ItemConf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // OrderedMap init. + const orderedMap: ItemConf.OrderedMap_ItemMap = new Map(); + for (const [k1Str, v1] of Object.entries(this.#data.itemMap)) { + const k1 = Number(k1Str); + orderedMap.set(k1, v1); + } + sortMapByKey(orderedMap, compareValues); + this.#orderedMap = orderedMap; + // Index init. + this.#indexItemMap.clear(); + this.#indexItemInfoMap.clear(); + this.#indexItemDefaultInfoMap.clear(); + this.#indexItemExtInfoMap.clear(); + this.#indexAwardItemMap.clear(); + this.#indexSpecialItemMap.clear(); + this.#indexItemPathDirMap.clear(); + this.#indexItemPathNameMap.clear(); + this.#indexItemPathFriendIDMap.clear(); + this.#indexUseEffectTypeMap.clear(); + for (const item1 of Object.values(this.#data.itemMap)) { + { + // Index: Type + const key = item1.type; + { + const list = this.#indexItemMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemMap.set(key, [item1]); } + } + } + { + // Index: Param@ItemInfo + for (const elem of item1.paramList ?? []) { + const key = elem; + { + const list = this.#indexItemInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemInfoMap.set(key, [item1]); } + } + } + } + { + // Index: Default@ItemDefaultInfo + const key = item1.default; + { + const list = this.#indexItemDefaultInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemDefaultInfoMap.set(key, [item1]); } + } + } + { + // Index: ExtType@ItemExtInfo + for (const elem of item1.extTypeList ?? []) { + const key = elem; + { + const list = this.#indexItemExtInfoMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemExtInfoMap.set(key, [item1]); } + } + } + } + { + // Index: (ID,Name)@AwardItem + const keyParts = [item1.id, item1.name]; + this.#indexAwardItemMap.getOrSet(keyParts, () => []).push(item1); + } + { + // Index: (ID,Type,Param,ExtType)@SpecialItem + for (const indexItem2 of item1.paramList ?? []) { + for (const indexItem3 of item1.extTypeList ?? []) { + const keyParts = [item1.id, item1.type, indexItem2, indexItem3]; + this.#indexSpecialItemMap.getOrSet(keyParts, () => []).push(item1); + } + } + } + { + // Index: PathDir@ItemPathDir + const key = item1.path?.dir ?? ""; + { + const list = this.#indexItemPathDirMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathDirMap.set(key, [item1]); } + } + } + { + // Index: PathName@ItemPathName + for (const elem of item1.path?.nameList ?? []) { + const key = elem; + { + const list = this.#indexItemPathNameMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathNameMap.set(key, [item1]); } + } + } + } + { + // Index: PathFriendID@ItemPathFriendID + const key = item1.path?.friend?.id ?? 0; + { + const list = this.#indexItemPathFriendIDMap.get(key); + if (list) { list.push(item1); } else { this.#indexItemPathFriendIDMap.set(key, [item1]); } + } + } + { + // Index: UseEffectType@UseEffectType + const key = item1.useEffect?.type ?? 0; + { + const list = this.#indexUseEffectTypeMap.get(key); + if (list) { list.push(item1); } else { this.#indexUseEffectTypeMap.set(key, [item1]); } + } + } + } + // Index(sort): Param@ItemInfo + const cmpItemInfo = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#indexItemInfoMap.values()) { + list.sort(cmpItemInfo); + } + // Index(sort): (ID,Name)@AwardItem + const cmpAwardItem = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.type, a.useEffect?.type ?? 0], [b.type, b.useEffect?.type ?? 0]); + for (const list of this.#indexAwardItemMap.values()) { + list.sort(cmpAwardItem); + } + // OrderedIndex init. + this.#orderedIndexExtTypeMap.clear(); + this.#orderedIndexParamExtTypeMap.clear(); + for (const item1 of Object.values(this.#data.itemMap)) { + { + // OrderedIndex: ExtType@ExtType + for (const elem of item1.extTypeList ?? []) { + const key = elem; + { + const list = this.#orderedIndexExtTypeMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexExtTypeMap.set(key, [item1]); } + } + } + } + { + // OrderedIndex: (Param,ExtType)@ParamExtType + for (const indexItem0 of item1.paramList ?? []) { + for (const indexItem1 of item1.extTypeList ?? []) { + const keyParts = [indexItem0, indexItem1]; + this.#orderedIndexParamExtTypeMap.getOrSet(keyParts, () => []).push(item1); + } + } + } + } + // OrderedIndex(sort): (Param,ExtType)@ParamExtType + const cmpParamExtType = (a: protoconf.ItemConf_Item, b: protoconf.ItemConf_Item): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexParamExtTypeMap.values()) { + list.sort(cmpParamExtType); + } + this.#orderedIndexExtTypeMap = sortMapByKey(this.#orderedIndexExtTypeMap, compareValues); + this.#orderedIndexParamExtTypeMap.sortKeys(); } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(id: number): protoconf.ItemConf_Item | undefined { - return this.data_.itemMap[id]; + return this.#data.itemMap[id]; + } + + /** getOrderedMap returns the 1st-level ordered map (sorted by key). */ + getOrderedMap(): ItemConf.OrderedMap_ItemMap { + return this.#orderedMap; + } + + /** findItemMap returns the index map: key(Type) -> values. */ + findItemMap(): ItemConf.Index_ItemMap { + return this.#indexItemMap; + } + + /** findItem returns all values for the given key(s), or undefined. */ + findItem(type: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemMap.get(type); + } + + /** findFirstItem returns the first value for the given key(s), or undefined. */ + findFirstItem(type: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findItem(type)?.[0]; + } + + /** findItemInfoMap returns the index map: key(Param@ItemInfo) -> values. */ + findItemInfoMap(): ItemConf.Index_ItemInfoMap { + return this.#indexItemInfoMap; + } + + /** findItemInfo returns all values for the given key(s), or undefined. */ + findItemInfo(param: number): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemInfoMap.get(param); + } + + /** findFirstItemInfo returns the first value for the given key(s), or undefined. */ + findFirstItemInfo(param: number): protoconf.ItemConf_Item | undefined { + return this.findItemInfo(param)?.[0]; + } + + /** findItemDefaultInfoMap returns the index map: key(Default@ItemDefaultInfo) -> values. */ + findItemDefaultInfoMap(): ItemConf.Index_ItemDefaultInfoMap { + return this.#indexItemDefaultInfoMap; + } + + /** findItemDefaultInfo returns all values for the given key(s), or undefined. */ + findItemDefaultInfo(default_: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemDefaultInfoMap.get(default_); + } + + /** findFirstItemDefaultInfo returns the first value for the given key(s), or undefined. */ + findFirstItemDefaultInfo(default_: string): protoconf.ItemConf_Item | undefined { + return this.findItemDefaultInfo(default_)?.[0]; + } + + /** findItemExtInfoMap returns the index map: key(ExtType@ItemExtInfo) -> values. */ + findItemExtInfoMap(): ItemConf.Index_ItemExtInfoMap { + return this.#indexItemExtInfoMap; + } + + /** findItemExtInfo returns all values for the given key(s), or undefined. */ + findItemExtInfo(extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemExtInfoMap.get(extType); + } + + /** findFirstItemExtInfo returns the first value for the given key(s), or undefined. */ + findFirstItemExtInfo(extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findItemExtInfo(extType)?.[0]; + } + + /** findAwardItemMap returns the index map: key((ID,Name)@AwardItem) -> values. */ + findAwardItemMap(): ItemConf.Index_AwardItemMap { + return this.#indexAwardItemMap; + } + + /** findAwardItem returns all values for the given key(s), or undefined. */ + findAwardItem(id: number, name: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexAwardItemMap.get([id, name]); + } + + /** findFirstAwardItem returns the first value for the given key(s), or undefined. */ + findFirstAwardItem(id: number, name: string): protoconf.ItemConf_Item | undefined { + return this.findAwardItem(id, name)?.[0]; + } + + /** findSpecialItemMap returns the index map: key((ID,Type,Param,ExtType)@SpecialItem) -> values. */ + findSpecialItemMap(): ItemConf.Index_SpecialItemMap { + return this.#indexSpecialItemMap; } + + /** findSpecialItem returns all values for the given key(s), or undefined. */ + findSpecialItem(id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#indexSpecialItemMap.get([id, type, param, extType]); + } + + /** findFirstSpecialItem returns the first value for the given key(s), or undefined. */ + findFirstSpecialItem(id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findSpecialItem(id, type, param, extType)?.[0]; + } + + /** findItemPathDirMap returns the index map: key(PathDir@ItemPathDir) -> values. */ + findItemPathDirMap(): ItemConf.Index_ItemPathDirMap { + return this.#indexItemPathDirMap; + } + + /** findItemPathDir returns all values for the given key(s), or undefined. */ + findItemPathDir(dir: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathDirMap.get(dir); + } + + /** findFirstItemPathDir returns the first value for the given key(s), or undefined. */ + findFirstItemPathDir(dir: string): protoconf.ItemConf_Item | undefined { + return this.findItemPathDir(dir)?.[0]; + } + + /** findItemPathNameMap returns the index map: key(PathName@ItemPathName) -> values. */ + findItemPathNameMap(): ItemConf.Index_ItemPathNameMap { + return this.#indexItemPathNameMap; + } + + /** findItemPathName returns all values for the given key(s), or undefined. */ + findItemPathName(name: string): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathNameMap.get(name); + } + + /** findFirstItemPathName returns the first value for the given key(s), or undefined. */ + findFirstItemPathName(name: string): protoconf.ItemConf_Item | undefined { + return this.findItemPathName(name)?.[0]; + } + + /** findItemPathFriendIDMap returns the index map: key(PathFriendID@ItemPathFriendID) -> values. */ + findItemPathFriendIDMap(): ItemConf.Index_ItemPathFriendIDMap { + return this.#indexItemPathFriendIDMap; + } + + /** findItemPathFriendID returns all values for the given key(s), or undefined. */ + findItemPathFriendID(id: number): protoconf.ItemConf_Item[] | undefined { + return this.#indexItemPathFriendIDMap.get(id); + } + + /** findFirstItemPathFriendID returns the first value for the given key(s), or undefined. */ + findFirstItemPathFriendID(id: number): protoconf.ItemConf_Item | undefined { + return this.findItemPathFriendID(id)?.[0]; + } + + /** findUseEffectTypeMap returns the index map: key(UseEffectType@UseEffectType) -> values. */ + findUseEffectTypeMap(): ItemConf.Index_UseEffectTypeMap { + return this.#indexUseEffectTypeMap; + } + + /** findUseEffectType returns all values for the given key(s), or undefined. */ + findUseEffectType(type: protoconf.UseEffect_Type): protoconf.ItemConf_Item[] | undefined { + return this.#indexUseEffectTypeMap.get(type); + } + + /** findFirstUseEffectType returns the first value for the given key(s), or undefined. */ + findFirstUseEffectType(type: protoconf.UseEffect_Type): protoconf.ItemConf_Item | undefined { + return this.findUseEffectType(type)?.[0]; + } + + /** findExtTypeMap returns the ordered index map: key(ExtType@ExtType) -> values. */ + findExtTypeMap(): ItemConf.OrderedIndex_ExtTypeMap { + return this.#orderedIndexExtTypeMap; + } + + /** findExtType returns all values for the given key(s), or undefined. */ + findExtType(extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#orderedIndexExtTypeMap.get(extType); + } + + /** findFirstExtType returns the first value for the given key(s), or undefined. */ + findFirstExtType(extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findExtType(extType)?.[0]; + } + + /** findParamExtTypeMap returns the ordered index map: key((Param,ExtType)@ParamExtType) -> values. */ + findParamExtTypeMap(): ItemConf.OrderedIndex_ParamExtTypeMap { + return this.#orderedIndexParamExtTypeMap; + } + + /** findParamExtType returns all values for the given key(s), or undefined. */ + findParamExtType(param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item[] | undefined { + return this.#orderedIndexParamExtTypeMap.get([param, extType]); + } + + /** findFirstParamExtType returns the first value for the given key(s), or undefined. */ + findFirstParamExtType(param: number, extType: protoconf.FruitType): protoconf.ItemConf_Item | undefined { + return this.findParamExtType(param, extType)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. ItemConf.Index_XxxMap). +export namespace ItemConf { + /** OrderedMap_ItemMap is the 1st-level (leaf) ordered map: key -> value (sorted by key). */ + export type OrderedMap_ItemMap = Map; + /** Index_ItemMap is the index map: key(Type) -> values. */ + export type Index_ItemMap = Map; + /** Index_ItemInfoMap is the index map: key(Param@ItemInfo) -> values. */ + export type Index_ItemInfoMap = Map; + /** Index_ItemDefaultInfoMap is the index map: key(Default@ItemDefaultInfo) -> values. */ + export type Index_ItemDefaultInfoMap = Map; + /** Index_ItemExtInfoMap is the index map: key(ExtType@ItemExtInfo) -> values. */ + export type Index_ItemExtInfoMap = Map; + /** Index_AwardItemKey is the composite key of index: key((ID,Name)@AwardItem). */ + export type Index_AwardItemKey = readonly [id: number, name: string]; + /** Index_AwardItemMap is the index map: key((ID,Name)@AwardItem) -> values. */ + export type Index_AwardItemMap = TupleKeyMap; + /** Index_SpecialItemKey is the composite key of index: key((ID,Type,Param,ExtType)@SpecialItem). */ + export type Index_SpecialItemKey = readonly [id: number, type: protoconf.FruitType, param: number, extType: protoconf.FruitType]; + /** Index_SpecialItemMap is the index map: key((ID,Type,Param,ExtType)@SpecialItem) -> values. */ + export type Index_SpecialItemMap = TupleKeyMap; + /** Index_ItemPathDirMap is the index map: key(PathDir@ItemPathDir) -> values. */ + export type Index_ItemPathDirMap = Map; + /** Index_ItemPathNameMap is the index map: key(PathName@ItemPathName) -> values. */ + export type Index_ItemPathNameMap = Map; + /** Index_ItemPathFriendIDMap is the index map: key(PathFriendID@ItemPathFriendID) -> values. */ + export type Index_ItemPathFriendIDMap = Map; + /** Index_UseEffectTypeMap is the index map: key(UseEffectType@UseEffectType) -> values. */ + export type Index_UseEffectTypeMap = Map; + /** OrderedIndex_ExtTypeMap is the ordered index map: key(ExtType@ExtType) -> values. */ + export type OrderedIndex_ExtTypeMap = Map; + /** OrderedIndex_ParamExtTypeKey is the composite key of ordered index: key((Param,ExtType)@ParamExtType). */ + export type OrderedIndex_ParamExtTypeKey = readonly [param: number, extType: protoconf.FruitType]; + /** OrderedIndex_ParamExtTypeMap is the ordered index map: key((Param,ExtType)@ParamExtType) -> values. */ + export type OrderedIndex_ParamExtTypeMap = TupleKeyMap; } diff --git a/test/ts-tableau-loader/tableau/patch_conf.pc.ts b/test/ts-tableau-loader/tableau/patch_conf.pc.ts index e3ebe33..4b5c6a5 100644 --- a/test/ts-tableau-loader/tableau/patch_conf.pc.ts +++ b/test/ts-tableau-loader/tableau/patch_conf.pc.ts @@ -15,7 +15,7 @@ import * as protoconf from "./barrel/protoconf.pc.js"; * PatchReplaceConf is a wrapper around protobuf message protoconf.PatchReplaceConf. */ export class PatchReplaceConf extends Messager { - private data_: protoconf.PatchReplaceConf = create(protoconf.PatchReplaceConfSchema); + #data: protoconf.PatchReplaceConf = create(protoconf.PatchReplaceConfSchema); /** name returns the PatchReplaceConf's message name. */ name(): string { @@ -26,7 +26,7 @@ export class PatchReplaceConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.PatchReplaceConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.PatchReplaceConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load PatchReplaceConf`, { cause: e }); } @@ -36,12 +36,12 @@ export class PatchReplaceConf extends Messager { /** data returns the PatchReplaceConf's inner message data. */ data(): protoconf.PatchReplaceConf { - return this.data_; + return this.#data; } /** message returns the PatchReplaceConf's inner message data. */ override message(): protoconf.PatchReplaceConf { - return this.data_; + return this.#data; } } @@ -49,7 +49,7 @@ export class PatchReplaceConf extends Messager { * PatchMergeConf is a wrapper around protobuf message protoconf.PatchMergeConf. */ export class PatchMergeConf extends Messager { - private data_: protoconf.PatchMergeConf = create(protoconf.PatchMergeConfSchema); + #data: protoconf.PatchMergeConf = create(protoconf.PatchMergeConfSchema); /** name returns the PatchMergeConf's message name. */ name(): string { @@ -60,7 +60,7 @@ export class PatchMergeConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.PatchMergeConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.PatchMergeConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load PatchMergeConf`, { cause: e }); } @@ -70,17 +70,17 @@ export class PatchMergeConf extends Messager { /** data returns the PatchMergeConf's inner message data. */ data(): protoconf.PatchMergeConf { - return this.data_; + return this.#data; } /** message returns the PatchMergeConf's inner message data. */ override message(): protoconf.PatchMergeConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(id: number): protoconf.Item | undefined { - return this.data_.itemMap[id]; + return this.#data.itemMap[id]; } } @@ -88,7 +88,7 @@ export class PatchMergeConf extends Messager { * RecursivePatchConf is a wrapper around protobuf message protoconf.RecursivePatchConf. */ export class RecursivePatchConf extends Messager { - private data_: protoconf.RecursivePatchConf = create(protoconf.RecursivePatchConfSchema); + #data: protoconf.RecursivePatchConf = create(protoconf.RecursivePatchConfSchema); /** name returns the RecursivePatchConf's message name. */ name(): string { @@ -99,7 +99,7 @@ export class RecursivePatchConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.RecursivePatchConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.RecursivePatchConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load RecursivePatchConf`, { cause: e }); } @@ -109,17 +109,17 @@ export class RecursivePatchConf extends Messager { /** data returns the RecursivePatchConf's inner message data. */ data(): protoconf.RecursivePatchConf { - return this.data_; + return this.#data; } /** message returns the RecursivePatchConf's inner message data. */ override message(): protoconf.RecursivePatchConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(shopId: number): protoconf.RecursivePatchConf_Shop | undefined { - return this.data_.shopMap[shopId]; + return this.#data.shopMap[shopId]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ diff --git a/test/ts-tableau-loader/tableau/test_conf.pc.ts b/test/ts-tableau-loader/tableau/test_conf.pc.ts index b5d20ed..4e492df 100644 --- a/test/ts-tableau-loader/tableau/test_conf.pc.ts +++ b/test/ts-tableau-loader/tableau/test_conf.pc.ts @@ -7,7 +7,7 @@ import { create } from "@bufbuild/protobuf"; import { Messager } from "./messager.pc.js"; -import { Format } from "./util.pc.js"; +import { Format, compareValues, compareTuples, sortMapByKey, TupleKeyMap, type OrderedMapValue } from "./util.pc.js"; import { loadMessagerInDir, type MessagerOptions } from "./load.pc.js"; import * as protoconf from "./barrel/protoconf.pc.js"; @@ -15,7 +15,17 @@ import * as protoconf from "./barrel/protoconf.pc.js"; * ActivityConf is a wrapper around protobuf message protoconf.ActivityConf. */ export class ActivityConf extends Messager { - private data_: protoconf.ActivityConf = create(protoconf.ActivityConfSchema); + #data: protoconf.ActivityConf = create(protoconf.ActivityConfSchema); + #orderedMap: ActivityConf.OrderedMap_ActivityMap = new Map(); + #indexActivityMap: ActivityConf.Index_ActivityMap = new Map(); + #indexChapterMap: ActivityConf.Index_ChapterMap = new Map(); + #indexChapterMap1: Map = new Map(); + #indexNamedChapterMap: ActivityConf.Index_NamedChapterMap = new Map(); + #indexNamedChapterMap1: Map = new Map(); + #indexAwardMap: ActivityConf.Index_AwardMap = new Map(); + #indexAwardMap1: Map = new Map(); + #indexAwardMap2: TupleKeyMap = new TupleKeyMap(); + #indexAwardMap3: TupleKeyMap = new TupleKeyMap(); /** name returns the ActivityConf's message name. */ name(): string { @@ -26,7 +36,7 @@ export class ActivityConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.ActivityConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.ActivityConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load ActivityConf`, { cause: e }); } @@ -36,17 +46,148 @@ export class ActivityConf extends Messager { /** data returns the ActivityConf's inner message data. */ data(): protoconf.ActivityConf { - return this.data_; + return this.#data; } /** message returns the ActivityConf's inner message data. */ override message(): protoconf.ActivityConf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // OrderedMap init. + const orderedMap: ActivityConf.OrderedMap_ActivityMap = new Map(); + for (const [k1Str, v1] of Object.entries(this.#data.activityMap)) { + const k1 = BigInt(k1Str); + const orderedMap2: ActivityConf.OrderedMap_Activity_ChapterMap = new Map(); + for (const [k2Str, v2] of Object.entries(v1.chapterMap)) { + const k2 = Number(k2Str); + const orderedMap3: ActivityConf.OrderedMap_protoconf_SectionMap = new Map(); + for (const [k3Str, v3] of Object.entries(v2.sectionMap)) { + const k3 = Number(k3Str); + const orderedMap4: ActivityConf.OrderedMap_int32Map = new Map(); + for (const [k4Str, v4] of Object.entries(v3.sectionRankMap)) { + const k4 = Number(k4Str); + orderedMap4.set(k4, v4); + } + sortMapByKey(orderedMap4, compareValues); + orderedMap3.set(k3, { first: orderedMap4, second: v3 }); + } + sortMapByKey(orderedMap3, compareValues); + orderedMap2.set(k2, { first: orderedMap3, second: v2 }); + } + sortMapByKey(orderedMap2, compareValues); + orderedMap.set(k1, { first: orderedMap2, second: v1 }); + } + sortMapByKey(orderedMap, compareValues); + this.#orderedMap = orderedMap; + // Index init. + this.#indexActivityMap.clear(); + this.#indexChapterMap.clear(); + this.#indexChapterMap1.clear(); + this.#indexNamedChapterMap.clear(); + this.#indexNamedChapterMap1.clear(); + this.#indexAwardMap.clear(); + this.#indexAwardMap1.clear(); + this.#indexAwardMap2.clear(); + this.#indexAwardMap3.clear(); + for (const [k1Str, item1] of Object.entries(this.#data.activityMap)) { + const k1 = BigInt(k1Str); + { + // Index: ActivityName + const key = item1.activityName; + { + const list = this.#indexActivityMap.get(key); + if (list) { list.push(item1); } else { this.#indexActivityMap.set(key, [item1]); } + } + } + for (const [k2Str, item2] of Object.entries(item1.chapterMap)) { + const k2 = Number(k2Str); + { + // Index: ChapterID + const key = item2.chapterId; + { + const list = this.#indexChapterMap.get(key); + if (list) { list.push(item2); } else { this.#indexChapterMap.set(key, [item2]); } + } + { + let map = this.#indexChapterMap1.get(k1); + if (!map) { map = new Map(); this.#indexChapterMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + { + // Index: ChapterName@NamedChapter + const key = item2.chapterName; + { + const list = this.#indexNamedChapterMap.get(key); + if (list) { list.push(item2); } else { this.#indexNamedChapterMap.set(key, [item2]); } + } + { + let map = this.#indexNamedChapterMap1.get(k1); + if (!map) { map = new Map(); this.#indexNamedChapterMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item2); } else { map.set(key, [item2]); } + } + } + } + for (const [k3Str, item3] of Object.entries(item2.sectionMap)) { + const k3 = Number(k3Str); + for (const item4 of item3.sectionItemList ?? []) { + { + // Index: SectionItemID@Award + const key = item4.id; + { + const list = this.#indexAwardMap.get(key); + if (list) { list.push(item4); } else { this.#indexAwardMap.set(key, [item4]); } + } + { + let map = this.#indexAwardMap1.get(k1); + if (!map) { map = new Map(); this.#indexAwardMap1.set(k1, map); } + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAwardMap2.getOrSet([k1, k2], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + { + const map = this.#indexAwardMap3.getOrSet([k1, k2, k3], () => new Map()); + { + const list = map.get(key); + if (list) { list.push(item4); } else { map.set(key, [item4]); } + } + } + } + } + } + } + } + // Index(sort): ChapterName@NamedChapter + const cmpNamedChapter = (a: protoconf.ActivityConf_Activity_Chapter, b: protoconf.ActivityConf_Activity_Chapter): number => compareTuples([a.awardId], [b.awardId]); + for (const list of this.#indexNamedChapterMap.values()) { + list.sort(cmpNamedChapter); + } + for (const m of this.#indexNamedChapterMap1.values()) { + for (const list of m.values()) { + list.sort(cmpNamedChapter); + } + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(activityId: bigint): protoconf.ActivityConf_Activity | undefined { - return this.data_.activityMap[activityId.toString()]; + return this.#data.activityMap[activityId.toString()]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ @@ -63,13 +204,200 @@ export class ActivityConf extends Messager { get4(activityId: bigint, chapterId: number, sectionId: number, key4: number): number | undefined { return this.get3(activityId, chapterId, sectionId)?.sectionRankMap[key4]; } + + /** getOrderedMap returns the 1st-level ordered map (sorted by key). */ + getOrderedMap(): ActivityConf.OrderedMap_ActivityMap { + return this.#orderedMap; + } + + /** getOrderedMap1 returns the 2nd-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap1(activityId: bigint): ActivityConf.OrderedMap_Activity_ChapterMap | undefined { + return this.#orderedMap.get(activityId)?.first; + } + + /** getOrderedMap2 returns the 3rd-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap2(activityId: bigint, chapterId: number): ActivityConf.OrderedMap_protoconf_SectionMap | undefined { + return this.getOrderedMap1(activityId)?.get(chapterId)?.first; + } + + /** getOrderedMap3 returns the 4th-level ordered map scoped to the given upper key(s), or undefined. */ + getOrderedMap3(activityId: bigint, chapterId: number, sectionId: number): ActivityConf.OrderedMap_int32Map | undefined { + return this.getOrderedMap2(activityId, chapterId)?.get(sectionId)?.first; + } + + /** findActivityMap returns the index map: key(ActivityName) -> values. */ + findActivityMap(): ActivityConf.Index_ActivityMap { + return this.#indexActivityMap; + } + + /** findActivity returns all values for the given key(s), or undefined. */ + findActivity(activityName: string): protoconf.ActivityConf_Activity[] | undefined { + return this.#indexActivityMap.get(activityName); + } + + /** findFirstActivity returns the first value for the given key(s), or undefined. */ + findFirstActivity(activityName: string): protoconf.ActivityConf_Activity | undefined { + return this.findActivity(activityName)?.[0]; + } + + /** findChapterMap returns the index map: key(ChapterID) -> values. */ + findChapterMap(): ActivityConf.Index_ChapterMap { + return this.#indexChapterMap; + } + + /** findChapter returns all values for the given key(s), or undefined. */ + findChapter(chapterId: number): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.#indexChapterMap.get(chapterId); + } + + /** findFirstChapter returns the first value for the given key(s), or undefined. */ + findFirstChapter(chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findChapter(chapterId)?.[0]; + } + + /** findChapterMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findChapterMap1(activityId: bigint): ActivityConf.Index_ChapterMap | undefined { + return this.#indexChapterMap1.get(activityId); + } + + /** findChapter1 returns all values for the given key(s) within the upper 1st-level map. */ + findChapter1(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.findChapterMap1(activityId)?.get(chapterId); + } + + /** findFirstChapter1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstChapter1(activityId: bigint, chapterId: number): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findChapter1(activityId, chapterId)?.[0]; + } + + /** findNamedChapterMap returns the index map: key(ChapterName@NamedChapter) -> values. */ + findNamedChapterMap(): ActivityConf.Index_NamedChapterMap { + return this.#indexNamedChapterMap; + } + + /** findNamedChapter returns all values for the given key(s), or undefined. */ + findNamedChapter(chapterName: string): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.#indexNamedChapterMap.get(chapterName); + } + + /** findFirstNamedChapter returns the first value for the given key(s), or undefined. */ + findFirstNamedChapter(chapterName: string): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findNamedChapter(chapterName)?.[0]; + } + + /** findNamedChapterMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findNamedChapterMap1(activityId: bigint): ActivityConf.Index_NamedChapterMap | undefined { + return this.#indexNamedChapterMap1.get(activityId); + } + + /** findNamedChapter1 returns all values for the given key(s) within the upper 1st-level map. */ + findNamedChapter1(activityId: bigint, chapterName: string): protoconf.ActivityConf_Activity_Chapter[] | undefined { + return this.findNamedChapterMap1(activityId)?.get(chapterName); + } + + /** findFirstNamedChapter1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstNamedChapter1(activityId: bigint, chapterName: string): protoconf.ActivityConf_Activity_Chapter | undefined { + return this.findNamedChapter1(activityId, chapterName)?.[0]; + } + + /** findAwardMap returns the index map: key(SectionItemID@Award) -> values. */ + findAwardMap(): ActivityConf.Index_AwardMap { + return this.#indexAwardMap; + } + + /** findAward returns all values for the given key(s), or undefined. */ + findAward(id: number): protoconf.Section_SectionItem[] | undefined { + return this.#indexAwardMap.get(id); + } + + /** findFirstAward returns the first value for the given key(s), or undefined. */ + findFirstAward(id: number): protoconf.Section_SectionItem | undefined { + return this.findAward(id)?.[0]; + } + + /** findAwardMap1 returns the index map scoped to the upper 1st-level map key(s). */ + findAwardMap1(activityId: bigint): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap1.get(activityId); + } + + /** findAward1 returns all values for the given key(s) within the upper 1st-level map. */ + findAward1(activityId: bigint, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap1(activityId)?.get(id); + } + + /** findFirstAward1 returns the first value for the given key(s) within the upper 1st-level map. */ + findFirstAward1(activityId: bigint, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward1(activityId, id)?.[0]; + } + + /** findAwardMap2 returns the index map scoped to the upper 2nd-level map key(s). */ + findAwardMap2(activityId: bigint, chapterId: number): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap2.get([activityId, chapterId]); + } + + /** findAward2 returns all values for the given key(s) within the upper 2nd-level map. */ + findAward2(activityId: bigint, chapterId: number, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap2(activityId, chapterId)?.get(id); + } + + /** findFirstAward2 returns the first value for the given key(s) within the upper 2nd-level map. */ + findFirstAward2(activityId: bigint, chapterId: number, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward2(activityId, chapterId, id)?.[0]; + } + + /** findAwardMap3 returns the index map scoped to the upper 3rd-level map key(s). */ + findAwardMap3(activityId: bigint, chapterId: number, sectionId: number): ActivityConf.Index_AwardMap | undefined { + return this.#indexAwardMap3.get([activityId, chapterId, sectionId]); + } + + /** findAward3 returns all values for the given key(s) within the upper 3rd-level map. */ + findAward3(activityId: bigint, chapterId: number, sectionId: number, id: number): protoconf.Section_SectionItem[] | undefined { + return this.findAwardMap3(activityId, chapterId, sectionId)?.get(id); + } + + /** findFirstAward3 returns the first value for the given key(s) within the upper 3rd-level map. */ + findFirstAward3(activityId: bigint, chapterId: number, sectionId: number, id: number): protoconf.Section_SectionItem | undefined { + return this.findAward3(activityId, chapterId, sectionId, id)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. ActivityConf.Index_XxxMap). +export namespace ActivityConf { + /** OrderedMap_int32Map is the 4th-level (leaf) ordered map: key -> value (sorted by key). */ + export type OrderedMap_int32Map = Map; + /** OrderedMap_protoconf_SectionValue is a 3rd-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_protoconf_SectionValue = OrderedMapValue; + /** OrderedMap_protoconf_SectionMap is the 3rd-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_protoconf_SectionMap = Map; + /** OrderedMap_Activity_ChapterValue is a 2nd-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_Activity_ChapterValue = OrderedMapValue; + /** OrderedMap_Activity_ChapterMap is the 2nd-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_Activity_ChapterMap = Map; + /** OrderedMap_ActivityValue is a 1st-level node: its next-level sub-map (first) plus this level's value (second). */ + export type OrderedMap_ActivityValue = OrderedMapValue; + /** OrderedMap_ActivityMap is the 1st-level ordered map: key -> node (sorted by key). */ + export type OrderedMap_ActivityMap = Map; + /** LevelIndex_Activity_ChapterKey is the composite upper map key (k1..k2) of the 2nd-level leveled containers. */ + export type LevelIndex_Activity_ChapterKey = readonly [activityId: bigint, chapterId: number]; + /** LevelIndex_protoconf_SectionKey is the composite upper map key (k1..k3) of the 3rd-level leveled containers. */ + export type LevelIndex_protoconf_SectionKey = readonly [activityId: bigint, chapterId: number, sectionId: number]; + /** Index_ActivityMap is the index map: key(ActivityName) -> values. */ + export type Index_ActivityMap = Map; + /** Index_ChapterMap is the index map: key(ChapterID) -> values. */ + export type Index_ChapterMap = Map; + /** Index_NamedChapterMap is the index map: key(ChapterName@NamedChapter) -> values. */ + export type Index_NamedChapterMap = Map; + /** Index_AwardMap is the index map: key(SectionItemID@Award) -> values. */ + export type Index_AwardMap = Map; } /** * ChapterConf is a wrapper around protobuf message protoconf.ChapterConf. */ export class ChapterConf extends Messager { - private data_: protoconf.ChapterConf = create(protoconf.ChapterConfSchema); + #data: protoconf.ChapterConf = create(protoconf.ChapterConfSchema); /** name returns the ChapterConf's message name. */ name(): string { @@ -80,7 +408,7 @@ export class ChapterConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.ChapterConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.ChapterConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load ChapterConf`, { cause: e }); } @@ -90,17 +418,17 @@ export class ChapterConf extends Messager { /** data returns the ChapterConf's inner message data. */ data(): protoconf.ChapterConf { - return this.data_; + return this.#data; } /** message returns the ChapterConf's inner message data. */ override message(): protoconf.ChapterConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(id: bigint): protoconf.ChapterConf_Chapter | undefined { - return this.data_.chapterMap[id.toString()]; + return this.#data.chapterMap[id.toString()]; } } @@ -108,7 +436,7 @@ export class ChapterConf extends Messager { * ThemeConf is a wrapper around protobuf message protoconf.ThemeConf. */ export class ThemeConf extends Messager { - private data_: protoconf.ThemeConf = create(protoconf.ThemeConfSchema); + #data: protoconf.ThemeConf = create(protoconf.ThemeConfSchema); /** name returns the ThemeConf's message name. */ name(): string { @@ -119,7 +447,7 @@ export class ThemeConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.ThemeConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.ThemeConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load ThemeConf`, { cause: e }); } @@ -129,17 +457,17 @@ export class ThemeConf extends Messager { /** data returns the ThemeConf's inner message data. */ data(): protoconf.ThemeConf { - return this.data_; + return this.#data; } /** message returns the ThemeConf's inner message data. */ override message(): protoconf.ThemeConf { - return this.data_; + return this.#data; } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(name: string): protoconf.ThemeConf_Theme | undefined { - return this.data_.themeMap[name]; + return this.#data.themeMap[name]; } /** get2 finds value in the 2nd-level map; returns undefined if not found. */ @@ -152,7 +480,12 @@ export class ThemeConf extends Messager { * TaskConf is a wrapper around protobuf message protoconf.TaskConf. */ export class TaskConf extends Messager { - private data_: protoconf.TaskConf = create(protoconf.TaskConfSchema); + #data: protoconf.TaskConf = create(protoconf.TaskConfSchema); + #indexTaskMap: TaskConf.Index_TaskMap = new Map(); + #orderedIndexOrderedTaskMap: TaskConf.OrderedIndex_OrderedTaskMap = new Map(); + #orderedIndexTaskExpiryMap: TaskConf.OrderedIndex_TaskExpiryMap = new Map(); + #orderedIndexSortedTaskExpiryMap: TaskConf.OrderedIndex_SortedTaskExpiryMap = new Map(); + #orderedIndexActivityExpiryMap: TaskConf.OrderedIndex_ActivityExpiryMap = new TupleKeyMap(); /** name returns the TaskConf's message name. */ name(): string { @@ -163,7 +496,7 @@ export class TaskConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.TaskConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.TaskConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load TaskConf`, { cause: e }); } @@ -173,25 +506,199 @@ export class TaskConf extends Messager { /** data returns the TaskConf's inner message data. */ data(): protoconf.TaskConf { - return this.data_; + return this.#data; } /** message returns the TaskConf's inner message data. */ override message(): protoconf.TaskConf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexTaskMap.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // Index: ActivityID + const key = item1.activityId; + { + const list = this.#indexTaskMap.get(key); + if (list) { list.push(item1); } else { this.#indexTaskMap.set(key, [item1]); } + } + } + } + // Index(sort): ActivityID + const cmpTask = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.goal, a.id], [b.goal, b.id]); + for (const list of this.#indexTaskMap.values()) { + list.sort(cmpTask); + } + // OrderedIndex init. + this.#orderedIndexOrderedTaskMap.clear(); + this.#orderedIndexTaskExpiryMap.clear(); + this.#orderedIndexSortedTaskExpiryMap.clear(); + this.#orderedIndexActivityExpiryMap.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // OrderedIndex: Goal@OrderedTask + const key = item1.goal; + { + const list = this.#orderedIndexOrderedTaskMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexOrderedTaskMap.set(key, [item1]); } + } + } + { + // OrderedIndex: Expiry@TaskExpiry + const key = item1.expiry?.seconds ?? 0n; + { + const list = this.#orderedIndexTaskExpiryMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexTaskExpiryMap.set(key, [item1]); } + } + } + { + // OrderedIndex: Expiry@SortedTaskExpiry + const key = item1.expiry?.seconds ?? 0n; + { + const list = this.#orderedIndexSortedTaskExpiryMap.get(key); + if (list) { list.push(item1); } else { this.#orderedIndexSortedTaskExpiryMap.set(key, [item1]); } + } + } + { + // OrderedIndex: (Expiry,ActivityID)@ActivityExpiry + const keyParts = [item1.expiry?.seconds ?? 0n, item1.activityId]; + this.#orderedIndexActivityExpiryMap.getOrSet(keyParts, () => []).push(item1); + } + } + // OrderedIndex(sort): Goal@OrderedTask + const cmpOrderedTask = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.id], [b.id]); + for (const list of this.#orderedIndexOrderedTaskMap.values()) { + list.sort(cmpOrderedTask); + } + // OrderedIndex(sort): Expiry@SortedTaskExpiry + const cmpSortedTaskExpiry = (a: protoconf.TaskConf_Task, b: protoconf.TaskConf_Task): number => compareTuples([a.goal, a.id], [b.goal, b.id]); + for (const list of this.#orderedIndexSortedTaskExpiryMap.values()) { + list.sort(cmpSortedTaskExpiry); + } + this.#orderedIndexOrderedTaskMap = sortMapByKey(this.#orderedIndexOrderedTaskMap, compareValues); + this.#orderedIndexTaskExpiryMap = sortMapByKey(this.#orderedIndexTaskExpiryMap, compareValues); + this.#orderedIndexSortedTaskExpiryMap = sortMapByKey(this.#orderedIndexSortedTaskExpiryMap, compareValues); + this.#orderedIndexActivityExpiryMap.sortKeys(); } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(id: bigint): protoconf.TaskConf_Task | undefined { - return this.data_.taskMap[id.toString()]; + return this.#data.taskMap[id.toString()]; + } + + /** findTaskMap returns the index map: key(ActivityID) -> values. */ + findTaskMap(): TaskConf.Index_TaskMap { + return this.#indexTaskMap; + } + + /** findTask returns all values for the given key(s), or undefined. */ + findTask(activityId: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#indexTaskMap.get(activityId); + } + + /** findFirstTask returns the first value for the given key(s), or undefined. */ + findFirstTask(activityId: bigint): protoconf.TaskConf_Task | undefined { + return this.findTask(activityId)?.[0]; + } + + /** findOrderedTaskMap returns the ordered index map: key(Goal@OrderedTask) -> values. */ + findOrderedTaskMap(): TaskConf.OrderedIndex_OrderedTaskMap { + return this.#orderedIndexOrderedTaskMap; + } + + /** findOrderedTask returns all values for the given key(s), or undefined. */ + findOrderedTask(goal: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexOrderedTaskMap.get(goal); } + + /** findFirstOrderedTask returns the first value for the given key(s), or undefined. */ + findFirstOrderedTask(goal: bigint): protoconf.TaskConf_Task | undefined { + return this.findOrderedTask(goal)?.[0]; + } + + /** findTaskExpiryMap returns the ordered index map: key(Expiry@TaskExpiry) -> values. */ + findTaskExpiryMap(): TaskConf.OrderedIndex_TaskExpiryMap { + return this.#orderedIndexTaskExpiryMap; + } + + /** findTaskExpiry returns all values for the given key(s), or undefined. */ + findTaskExpiry(expiry: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexTaskExpiryMap.get(expiry); + } + + /** findFirstTaskExpiry returns the first value for the given key(s), or undefined. */ + findFirstTaskExpiry(expiry: bigint): protoconf.TaskConf_Task | undefined { + return this.findTaskExpiry(expiry)?.[0]; + } + + /** findSortedTaskExpiryMap returns the ordered index map: key(Expiry@SortedTaskExpiry) -> values. */ + findSortedTaskExpiryMap(): TaskConf.OrderedIndex_SortedTaskExpiryMap { + return this.#orderedIndexSortedTaskExpiryMap; + } + + /** findSortedTaskExpiry returns all values for the given key(s), or undefined. */ + findSortedTaskExpiry(expiry: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexSortedTaskExpiryMap.get(expiry); + } + + /** findFirstSortedTaskExpiry returns the first value for the given key(s), or undefined. */ + findFirstSortedTaskExpiry(expiry: bigint): protoconf.TaskConf_Task | undefined { + return this.findSortedTaskExpiry(expiry)?.[0]; + } + + /** findActivityExpiryMap returns the ordered index map: key((Expiry,ActivityID)@ActivityExpiry) -> values. */ + findActivityExpiryMap(): TaskConf.OrderedIndex_ActivityExpiryMap { + return this.#orderedIndexActivityExpiryMap; + } + + /** findActivityExpiry returns all values for the given key(s), or undefined. */ + findActivityExpiry(expiry: bigint, activityId: bigint): protoconf.TaskConf_Task[] | undefined { + return this.#orderedIndexActivityExpiryMap.get([expiry, activityId]); + } + + /** findFirstActivityExpiry returns the first value for the given key(s), or undefined. */ + findFirstActivityExpiry(expiry: bigint, activityId: bigint): protoconf.TaskConf_Task | undefined { + return this.findActivityExpiry(expiry, activityId)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. TaskConf.Index_XxxMap). +export namespace TaskConf { + /** Index_TaskMap is the index map: key(ActivityID) -> values. */ + export type Index_TaskMap = Map; + /** OrderedIndex_OrderedTaskMap is the ordered index map: key(Goal@OrderedTask) -> values. */ + export type OrderedIndex_OrderedTaskMap = Map; + /** OrderedIndex_TaskExpiryMap is the ordered index map: key(Expiry@TaskExpiry) -> values. */ + export type OrderedIndex_TaskExpiryMap = Map; + /** OrderedIndex_SortedTaskExpiryMap is the ordered index map: key(Expiry@SortedTaskExpiry) -> values. */ + export type OrderedIndex_SortedTaskExpiryMap = Map; + /** OrderedIndex_ActivityExpiryKey is the composite key of ordered index: key((Expiry,ActivityID)@ActivityExpiry). */ + export type OrderedIndex_ActivityExpiryKey = readonly [expiry: bigint, activityId: bigint]; + /** OrderedIndex_ActivityExpiryMap is the ordered index map: key((Expiry,ActivityID)@ActivityExpiry) -> values. */ + export type OrderedIndex_ActivityExpiryMap = TupleKeyMap; } /** * StrcaseConf is a wrapper around protobuf message protoconf.StrcaseConf. */ export class StrcaseConf extends Messager { - private data_: protoconf.StrcaseConf = create(protoconf.StrcaseConfSchema); + #data: protoconf.StrcaseConf = create(protoconf.StrcaseConfSchema); + #indexIndex1Map: StrcaseConf.Index_Index1Map = new Map(); + #indexIndex2Map: StrcaseConf.Index_Index2Map = new Map(); + #indexIndex3Map: StrcaseConf.Index_Index3Map = new Map(); + #indexIndex4Map: StrcaseConf.Index_Index4Map = new Map(); + #indexIndex5Map: StrcaseConf.Index_Index5Map = new Map(); + #indexIndex6Map: StrcaseConf.Index_Index6Map = new Map(); + #indexIndex7Map: StrcaseConf.Index_Index7Map = new Map(); + #indexIndex8Map: StrcaseConf.Index_Index8Map = new Map(); + #indexIndex9Map: StrcaseConf.Index_Index9Map = new Map(); + #indexIndex10Map: StrcaseConf.Index_Index10Map = new Map(); /** name returns the StrcaseConf's message name. */ name(): string { @@ -202,7 +709,7 @@ export class StrcaseConf extends Messager { load(dir: string, fmt: Format, options?: MessagerOptions): void { const start = Date.now(); try { - this.data_ = loadMessagerInDir(protoconf.StrcaseConfSchema, dir, fmt, options); + this.#data = loadMessagerInDir(protoconf.StrcaseConfSchema, dir, fmt, options); } catch (e) { throw new Error(`failed to load StrcaseConf`, { cause: e }); } @@ -212,16 +719,289 @@ export class StrcaseConf extends Messager { /** data returns the StrcaseConf's inner message data. */ data(): protoconf.StrcaseConf { - return this.data_; + return this.#data; } /** message returns the StrcaseConf's inner message data. */ override message(): protoconf.StrcaseConf { - return this.data_; + return this.#data; + } + + /** processAfterLoad builds the index, ordered index and ordered map containers. */ + override processAfterLoad(): void { + // Index init. + this.#indexIndex1Map.clear(); + this.#indexIndex2Map.clear(); + this.#indexIndex3Map.clear(); + this.#indexIndex4Map.clear(); + this.#indexIndex5Map.clear(); + this.#indexIndex6Map.clear(); + this.#indexIndex7Map.clear(); + this.#indexIndex8Map.clear(); + this.#indexIndex9Map.clear(); + this.#indexIndex10Map.clear(); + for (const item1 of Object.values(this.#data.taskMap)) { + { + // Index: HTTPServer@Index1 + const key = item1.HTTPServer; + { + const list = this.#indexIndex1Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex1Map.set(key, [item1]); } + } + } + { + // Index: Fight1v1@Index2 + const key = item1.fight1v1; + { + const list = this.#indexIndex2Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex2Map.set(key, [item1]); } + } + } + { + // Index: SeasonRank@Index3 + const key = item1.SEASONRANK; + { + const list = this.#indexIndex3Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex3Map.set(key, [item1]); } + } + } + { + // Index: UserID@Index4 + const key = item1.userID; + { + const list = this.#indexIndex4Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex4Map.set(key, [item1]); } + } + } + { + // Index: Task@Index5 + const key = item1.task; + { + const list = this.#indexIndex5Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex5Map.set(key, [item1]); } + } + } + { + // Index: V2Ray@Index6 + const key = item1.v2Ray; + { + const list = this.#indexIndex6Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex6Map.set(key, [item1]); } + } + } + { + // Index: X@Index7 + const key = item1.x; + { + const list = this.#indexIndex7Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex7Map.set(key, [item1]); } + } + } + { + // Index: SomeField@Index8 + const key = item1.someField; + { + const list = this.#indexIndex8Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex8Map.set(key, [item1]); } + } + } + { + // Index: XCoordinate@Index9 + const key = item1.xCoordinate; + { + const list = this.#indexIndex9Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex9Map.set(key, [item1]); } + } + } + { + // Index: Class@Index10 + const key = item1.class; + { + const list = this.#indexIndex10Map.get(key); + if (list) { list.push(item1); } else { this.#indexIndex10Map.set(key, [item1]); } + } + } + } } /** get1 finds value in the 1st-level map; returns undefined if not found. */ get1(id: bigint): protoconf.StrcaseConf_Task | undefined { - return this.data_.taskMap[id.toString()]; + return this.#data.taskMap[id.toString()]; + } + + /** findIndex1Map returns the index map: key(HTTPServer@Index1) -> values. */ + findIndex1Map(): StrcaseConf.Index_Index1Map { + return this.#indexIndex1Map; + } + + /** findIndex1 returns all values for the given key(s), or undefined. */ + findIndex1(httpserver: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex1Map.get(httpserver); + } + + /** findFirstIndex1 returns the first value for the given key(s), or undefined. */ + findFirstIndex1(httpserver: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex1(httpserver)?.[0]; + } + + /** findIndex2Map returns the index map: key(Fight1v1@Index2) -> values. */ + findIndex2Map(): StrcaseConf.Index_Index2Map { + return this.#indexIndex2Map; } + + /** findIndex2 returns all values for the given key(s), or undefined. */ + findIndex2(fight1V1: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex2Map.get(fight1V1); + } + + /** findFirstIndex2 returns the first value for the given key(s), or undefined. */ + findFirstIndex2(fight1V1: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex2(fight1V1)?.[0]; + } + + /** findIndex3Map returns the index map: key(SeasonRank@Index3) -> values. */ + findIndex3Map(): StrcaseConf.Index_Index3Map { + return this.#indexIndex3Map; + } + + /** findIndex3 returns all values for the given key(s), or undefined. */ + findIndex3(seasonRank: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex3Map.get(seasonRank); + } + + /** findFirstIndex3 returns the first value for the given key(s), or undefined. */ + findFirstIndex3(seasonRank: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex3(seasonRank)?.[0]; + } + + /** findIndex4Map returns the index map: key(UserID@Index4) -> values. */ + findIndex4Map(): StrcaseConf.Index_Index4Map { + return this.#indexIndex4Map; + } + + /** findIndex4 returns all values for the given key(s), or undefined. */ + findIndex4(userId: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex4Map.get(userId); + } + + /** findFirstIndex4 returns the first value for the given key(s), or undefined. */ + findFirstIndex4(userId: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex4(userId)?.[0]; + } + + /** findIndex5Map returns the index map: key(Task@Index5) -> values. */ + findIndex5Map(): StrcaseConf.Index_Index5Map { + return this.#indexIndex5Map; + } + + /** findIndex5 returns all values for the given key(s), or undefined. */ + findIndex5(task: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex5Map.get(task); + } + + /** findFirstIndex5 returns the first value for the given key(s), or undefined. */ + findFirstIndex5(task: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex5(task)?.[0]; + } + + /** findIndex6Map returns the index map: key(V2Ray@Index6) -> values. */ + findIndex6Map(): StrcaseConf.Index_Index6Map { + return this.#indexIndex6Map; + } + + /** findIndex6 returns all values for the given key(s), or undefined. */ + findIndex6(v2Ray: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex6Map.get(v2Ray); + } + + /** findFirstIndex6 returns the first value for the given key(s), or undefined. */ + findFirstIndex6(v2Ray: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex6(v2Ray)?.[0]; + } + + /** findIndex7Map returns the index map: key(X@Index7) -> values. */ + findIndex7Map(): StrcaseConf.Index_Index7Map { + return this.#indexIndex7Map; + } + + /** findIndex7 returns all values for the given key(s), or undefined. */ + findIndex7(x: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex7Map.get(x); + } + + /** findFirstIndex7 returns the first value for the given key(s), or undefined. */ + findFirstIndex7(x: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex7(x)?.[0]; + } + + /** findIndex8Map returns the index map: key(SomeField@Index8) -> values. */ + findIndex8Map(): StrcaseConf.Index_Index8Map { + return this.#indexIndex8Map; + } + + /** findIndex8 returns all values for the given key(s), or undefined. */ + findIndex8(someField: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex8Map.get(someField); + } + + /** findFirstIndex8 returns the first value for the given key(s), or undefined. */ + findFirstIndex8(someField: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex8(someField)?.[0]; + } + + /** findIndex9Map returns the index map: key(XCoordinate@Index9) -> values. */ + findIndex9Map(): StrcaseConf.Index_Index9Map { + return this.#indexIndex9Map; + } + + /** findIndex9 returns all values for the given key(s), or undefined. */ + findIndex9(xcoordinate: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex9Map.get(xcoordinate); + } + + /** findFirstIndex9 returns the first value for the given key(s), or undefined. */ + findFirstIndex9(xcoordinate: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex9(xcoordinate)?.[0]; + } + + /** findIndex10Map returns the index map: key(Class@Index10) -> values. */ + findIndex10Map(): StrcaseConf.Index_Index10Map { + return this.#indexIndex10Map; + } + + /** findIndex10 returns all values for the given key(s), or undefined. */ + findIndex10(class_: bigint): protoconf.StrcaseConf_Task[] | undefined { + return this.#indexIndex10Map.get(class_); + } + + /** findFirstIndex10 returns the first value for the given key(s), or undefined. */ + findFirstIndex10(class_: bigint): protoconf.StrcaseConf_Task | undefined { + return this.findIndex10(class_)?.[0]; + } +} + +// Type aliases for the index / ordered index / ordered map containers, +// mirroring the named container types of the other-language loaders so the +// finder signatures read clearly (e.g. StrcaseConf.Index_XxxMap). +export namespace StrcaseConf { + /** Index_Index1Map is the index map: key(HTTPServer@Index1) -> values. */ + export type Index_Index1Map = Map; + /** Index_Index2Map is the index map: key(Fight1v1@Index2) -> values. */ + export type Index_Index2Map = Map; + /** Index_Index3Map is the index map: key(SeasonRank@Index3) -> values. */ + export type Index_Index3Map = Map; + /** Index_Index4Map is the index map: key(UserID@Index4) -> values. */ + export type Index_Index4Map = Map; + /** Index_Index5Map is the index map: key(Task@Index5) -> values. */ + export type Index_Index5Map = Map; + /** Index_Index6Map is the index map: key(V2Ray@Index6) -> values. */ + export type Index_Index6Map = Map; + /** Index_Index7Map is the index map: key(X@Index7) -> values. */ + export type Index_Index7Map = Map; + /** Index_Index8Map is the index map: key(SomeField@Index8) -> values. */ + export type Index_Index8Map = Map; + /** Index_Index9Map is the index map: key(XCoordinate@Index9) -> values. */ + export type Index_Index9Map = Map; + /** Index_Index10Map is the index map: key(Class@Index10) -> values. */ + export type Index_Index10Map = Map; } diff --git a/test/ts-tableau-loader/tableau/util.pc.ts b/test/ts-tableau-loader/tableau/util.pc.ts index 143b284..90fac73 100644 --- a/test/ts-tableau-loader/tableau/util.pc.ts +++ b/test/ts-tableau-loader/tableau/util.pc.ts @@ -183,3 +183,206 @@ function patchMap(dst: AnyMessage, src: AnyMessage, fd: DescField): void { } } +// --------------------------------------------------------------------------- +// Index container runtime +// +// Helpers shared by generated loaders to build index, ordered index and +// ordered map containers. +// --------------------------------------------------------------------------- + +/** + * makeIndexKey serializes a tuple of multi-column index key parts into a unique + * string so it can be used as a Map key with by-value equality (JS Maps compare + * object keys by reference, which would break composite keys). + * + * Each part is tagged with its runtime type to avoid collisions between values + * that stringify to the same text (e.g. the number 1 and the string "1"), and + * parts are joined with a NUL separator that cannot appear in normal strings. + * + * Module-private: it is an implementation detail of TupleKeyMap (the only + * caller). Generated loaders never serialize keys themselves; they go through + * TupleKeyMap, so this is intentionally not exported. + */ +function makeIndexKey(parts: readonly unknown[]): string { + return parts.map((p) => `${typeof p}:${String(p)}`).join("\u0000"); +} + +/** + * compareValues compares two ordered scalar key parts of the same logical type + * (number, bigint, string or boolean), returning a negative/zero/positive + * number suitable for Array.prototype.sort. + */ +export function compareValues(a: unknown, b: unknown): number { + if (typeof a === "bigint" || typeof b === "bigint") { + const x = BigInt(a as never); + const y = BigInt(b as never); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "string" || typeof b === "string") { + const x = String(a); + const y = String(b); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === "boolean" || typeof b === "boolean") { + return (a ? 1 : 0) - (b ? 1 : 0); + } + return (a as number) - (b as number); +} + +/** + * compareTuples compares two equal-length tuples of ordered scalar parts + * lexicographically. + */ +export function compareTuples(a: readonly unknown[], b: readonly unknown[]): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const c = compareValues(a[i], b[i]); + if (c !== 0) return c; + } + return a.length - b.length; +} + +/** + * sortMapByKey sorts a Map in place by key using the given comparator and + * returns the same Map instance. JS Maps preserve insertion order, so the + * entries are re-inserted in sorted order (an "ordered map"). Sorting in place + * (rather than returning a new Map) keeps the Map reference stable across + * reloads, so views cached over it (e.g. TupleKeyMap) stay valid. + */ +export function sortMapByKey(map: Map, cmp: (a: K, b: K) => number): Map { + const entries = [...map.entries()].sort((x, y) => cmp(x[0], y[0])); + map.clear(); + for (const [k, v] of entries) map.set(k, v); + return map; +} + +/** + * OrderedMapValue is the value stored at a non-leaf level of a nested ordered + * map. It pairs the next-level ordered (sub-)map with the message value at the + * current level, mirroring the Pair node the Go / C++ / C# + * loaders expose (Go .First/.Second, C# .Item1/.Item2, C++ .first/.second), so + * callers can both descend into the sub-map (.first) and read the level's own + * value (.second). Leaf levels store the plain value directly, without this + * wrapper. + * + * Type parameters: + * - M: the next-level ordered map type (a Map keyed in sorted order). + * - E: the message value at the current level. + */ +export interface OrderedMapValue { + /** first is the next-level ordered (sub-)map, sorted by key. */ + first: M; + /** second is the value at the current level. */ + second: E; +} + +/** + * TupleKeyMap is the multi-column index / ordered-index container. A composite + * key (e.g. [param, extType]) cannot be used directly as a JS Map key (Maps + * compare object keys by reference), so it owns an internal Map keyed by the + * opaque serialized string of the key columns (see makeIndexKey) plus a side + * table mapping that string back to the original key tuple. Lookups and + * iteration accept / return by-value tuple keys, mirroring the structured + * composite keys the Go / C++ / C# loaders expose for multi-column indexes + * (where the map key is a comparable key struct). + * + * Unlike a plain read-only view, it owns its storage and exposes a small build + * API (clear / getOrSet / sortKeys) used by the generated processAfterLoad, so + * a messager holds this single container per multi-column index (or per + * multi-key leveled container) instead of a raw Map, a separate serialized-key + * -> tuple side table, and a cached view. It is mutated in place across + * reloads, so its reference stays stable and callers holding the container + * returned by a finder stay valid. + * + * Type parameters: + * - K: the readonly tuple of key columns (e.g. readonly [id: number, + * name: string]); the generated loaders pass a named alias (e.g. + * ItemConf.Index_AwardItemKey) so lookups / iteration are fully typed. K is + * purely compile-time: the runtime always serializes via makeIndexKey, so + * the key type carries no runtime cost. + * - E: the value type stored under each key tuple. This is a plain tuple-keyed + * map (one key -> one value), so the value is whatever the caller stores: + * a value list (E = V[]) for a leaf multi-column index, or an inner index + * container for a multi-key leveled container. + */ +export class TupleKeyMap { + private readonly map = new Map(); + private readonly tuples = new Map(); + + /** size returns the number of key buckets. */ + get size(): number { + return this.map.size; + } + + /** clear empties the container (called at the start of each (re)build). */ + clear(): void { + this.map.clear(); + this.tuples.clear(); + } + + /** + * getOrSet returns the value stored under the given key columns, creating and + * inserting it via make() on first access (and recording the original key + * tuple then, for sortKeys and by-value key iteration). makeIndexKey is + * computed exactly once. The build side is generator-driven, so keyParts is + * loosely typed; the query side (get/has/keys/entries) stays strongly typed. + */ + getOrSet(keyParts: readonly unknown[], make: () => E): E { + const k = makeIndexKey(keyParts); + let v = this.map.get(k); + if (v === undefined) { + v = make(); + this.map.set(k, v); + this.tuples.set(k, keyParts); + } + return v; + } + + /** + * sortKeys reorders the buckets by comparing their key tuples + * lexicographically (used by ordered indexes). JS Maps preserve insertion + * order, so entries are re-inserted in sorted order. Sorting in place keeps + * the container reference stable across reloads. + */ + sortKeys(): void { + const entries = [...this.map.entries()].sort((x, y) => + compareTuples(this.tuples.get(x[0]) ?? [], this.tuples.get(y[0]) ?? []), + ); + this.map.clear(); + for (const [k, v] of entries) this.map.set(k, v); + } + + /** get returns the value stored under the given key columns, or undefined. */ + get(key: K): E | undefined { + return this.map.get(makeIndexKey(key)); + } + + /** has reports whether the given key columns are present. */ + has(key: K): boolean { + return this.map.has(makeIndexKey(key)); + } + + /** keys iterates the key-column tuples in container (sorted, if ordered) order. */ + *keys(): IterableIterator { + for (const k of this.map.keys()) { + yield (this.tuples.get(k) ?? []) as unknown as K; + } + } + + /** values iterates the values in container order. */ + values(): IterableIterator { + return this.map.values(); + } + + /** entries iterates [keyColumns, value] pairs in container order. */ + *entries(): IterableIterator<[K, E]> { + for (const [k, v] of this.map) { + yield [(this.tuples.get(k) ?? []) as unknown as K, v]; + } + } + + [Symbol.iterator](): IterableIterator<[K, E]> { + return this.entries(); + } +} + diff --git a/test/ts-tableau-loader/tests/get.test.ts b/test/ts-tableau-loader/tests/get.test.ts new file mode 100644 index 0000000..60e09c3 --- /dev/null +++ b/test/ts-tableau-loader/tests/get.test.ts @@ -0,0 +1,55 @@ +// Get block: point-lookup getters (map getters). Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/get_test.go +// - C#: test/csharp-tableau-loader/tests/GetTests.cs +// - C++: test/cpp-tableau-loader/tests/get_test.cpp +import { assert, check, fruitTypeApple, prepareHub } from "./harness.js"; + +export function run(): void { + // ---- ItemConf: first-level int-keyed map getter ---- + check("ItemConf map getter", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + assert.equal(item!.name(), "ItemConf"); + assert.equal(item!.get1(1)?.name, "apple"); + assert.equal(item!.get1(0)?.name, "coin1"); + assert.equal(item!.get1(12345), undefined); + }); + + // ---- ActivityConf: nested map getters with a 64-bit (bigint) outer key ---- + check("ActivityConf nested map getters", () => { + const activity = prepareHub().getActivityConf(); + assert.ok(activity); + assert.equal(activity!.get1(100001n)?.activityName, "活动1"); + assert.equal(activity!.get2(100001n, 1)?.chapterName, "签到活动章1"); + assert.equal(activity!.get3(100001n, 1, 2)?.sectionId, 2); + // sectionRankMap["2001"] === 2 + assert.equal(activity!.get4(100001n, 1, 2, 2001), 2); + // not found + assert.equal(activity!.get3(100001n, 1, 99), undefined); + }); + + // ---- ThemeConf: string-keyed map getter ---- + check("ThemeConf string-keyed map", () => { + const theme = prepareHub().getThemeConf(); + assert.ok(theme); + assert.ok(Object.keys(theme!.data().themeMap).length > 0); + }); + + // ---- FruitConf: leveled point getters ---- + check("FruitConf leveled getters", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + // get1: found / not found + assert.ok(fruit!.get1(fruitTypeApple)); + assert.equal(fruit!.get1(999), undefined); + + // get2: APPLE -> item 1001, price 10 + const item = fruit!.get2(fruitTypeApple, 1001); + assert.ok(item); + assert.equal(item!.id, 1001); + assert.equal(item!.price, 10); + assert.equal(fruit!.get2(fruitTypeApple, 9999), undefined); + assert.equal(fruit!.get2(999, 1001), undefined); + }); +} diff --git a/test/ts-tableau-loader/tests/harness.ts b/test/ts-tableau-loader/tests/harness.ts new file mode 100644 index 0000000..7266d97 --- /dev/null +++ b/test/ts-tableau-loader/tests/harness.ts @@ -0,0 +1,53 @@ +// Shared harness for the TS tableau loader tests. +// +// The unit tests are split into four blocks, one file each, mirroring the +// Go / C# / C++ test layout: +// - load.test.ts (Load, filter, Bin, Patch) +// - get.test.ts (point-lookup getters) +// - ordered_map.test.ts (getOrderedMap traversal) +// - index.test.ts (find* index finders) +// +// `tests/smoke.ts` is the entry point that runs all four blocks. +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { Hub } from "../tableau/hub.pc.js"; +import { Format } from "../tableau/load.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const testdata = join(here, "..", "..", "testdata"); +export const confDir = join(testdata, "conf"); +export const binDir = join(testdata, "bin"); +export const patchConf = join(testdata, "patchconf"); +export const patchConf2 = join(testdata, "patchconf2"); +export const patchResult = join(testdata, "patchresult"); + +// fruitType map keys (== FruitType enum values) used by FruitConf.json. +export const fruitTypeApple = protoconf.FruitType.APPLE; +export const fruitTypeOrange = protoconf.FruitType.ORANGE; +export const fruitTypeBanana = protoconf.FruitType.BANANA; + +export { assert, join }; + +let passed = 0; +export function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok - ${name}`); +} +export function passedCount(): number { + return passed; +} + +// Shared hub, loaded lazily once and reused across the Get / OrderedMap / Index +// blocks. Mirrors Go's prepareHub helper, C#'s HubFixture and C++'s HubFixture. +let sharedHub: Hub | undefined; +export function prepareHub(): Hub { + if (!sharedHub) { + sharedHub = new Hub(); + sharedHub.load(confDir, Format.JSON, { ignoreUnknownFields: true }); + } + return sharedHub; +} diff --git a/test/ts-tableau-loader/tests/index.test.ts b/test/ts-tableau-loader/tests/index.test.ts new file mode 100644 index 0000000..d907468 --- /dev/null +++ b/test/ts-tableau-loader/tests/index.test.ts @@ -0,0 +1,118 @@ +// Index block: index finders (leveled, ordered and multi-column). Mirrors: +// - Go: test/go-tableau-loader/index_test.go +// - C#: test/csharp-tableau-loader/tests/IndexTests.cs +// - C++: test/cpp-tableau-loader/tests/index_test.cpp +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +import { + assert, + check, + fruitTypeApple, + fruitTypeOrange, + fruitTypeBanana, + prepareHub, +} from "./harness.js"; + +export function run(): void { + // ---- FruitConf: leveled index (Price) finders ---- + check("FruitConf index finders", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + // global index: price -> items + assert.deepEqual(fruit!.findItem(10)?.map((i) => i.id), [1001]); + assert.equal(fruit!.findFirstItem(20)?.id, 1002); + assert.equal(fruit!.findItem(999), undefined); + // 6 items, all unique prices -> 6 entries + assert.equal(fruit!.findItemMap().size, 6); + + // 1st-level scoped index: (fruitType) -> price -> items + assert.deepEqual(fruit!.findItem1(fruitTypeOrange, 15)?.map((i) => i.id), [2001]); + assert.equal(fruit!.findFirstItem1(fruitTypeBanana, 8)?.id, 3001); + assert.equal(fruit!.findItemMap1(fruitTypeApple)?.size, 2); + assert.equal(fruit!.findItemMap1(999), undefined); + assert.equal(fruit!.findItem1(999, 15), undefined); + }); + + // ---- FruitConf: ordered index (Price@OrderedFruit) finders + ascending map ---- + check("FruitConf ordered index finders", () => { + const fruit = prepareHub().getFruitConf(); + assert.ok(fruit); + + assert.deepEqual(fruit!.findOrderedFruit(10)?.map((i) => i.id), [1001]); + assert.equal(fruit!.findFirstOrderedFruit(25)?.id, 2002); + assert.equal(fruit!.findOrderedFruit(999), undefined); + + // ordered map is keyed in ascending price order + const m = fruit!.findOrderedFruitMap(); + assert.equal(m.size, 6); + let prev = -Infinity; + for (const key of m.keys()) { + assert.ok(key >= prev, `ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + + // 1st-level scoped ordered index + assert.deepEqual(fruit!.findOrderedFruit1(fruitTypeOrange, 25)?.map((i) => i.id), [2002]); + assert.equal(fruit!.findFirstOrderedFruit1(fruitTypeBanana, 12)?.id, 3002); + assert.equal(fruit!.findOrderedFruit1(999, 25), undefined); + }); + + // ---- ItemConf: single-column index ---- + check("ItemConf findItemInfoMap non-empty", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + assert.ok(item!.findItemInfoMap().size > 0); + }); + + // ---- ItemConf: multi-column ordered index (ParamExtType) ---- + // Returns a TupleKeyMap whose keys are readable [param, extType] tuples (not + // opaque serialized strings), are sorted ascending, and round-trip through get(). + check("ItemConf multi-column ordered index (TupleKeyMap)", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + + // apple has param_list [1,2,3] x extTypeList [APPLE, ORANGE] -> 6 buckets. + const m = item!.findParamExtTypeMap(); + assert.equal(m.size, 6); + assert.equal([...m.keys()].length, 6); + + let prevParam = -Infinity; + for (const [key, values] of m) { + // keys are decoded 2-tuples, not opaque serialized strings + assert.equal(key.length, 2); + assert.ok((key[0] as number) >= prevParam, "ParamExtType keys not ascending by param"); + prevParam = key[0] as number; + // get(tuple) round-trips to the very same value list reference + assert.equal(m.get(key), values); + // and agrees with the point-lookup finder + assert.equal( + item!.findParamExtType(key[0] as number, key[1] as protoconf.FruitType), + values, + ); + } + + // a concrete known bucket: (param=1, extType=APPLE) -> apple + assert.deepEqual(m.get([1, fruitTypeApple])?.map((i) => i.name), ["apple"]); + assert.equal(m.get([999, fruitTypeApple]), undefined); + }); + + // ---- ItemConf: non-ordered multi-column index (AwardItem) ---- + // Also exposed as a TupleKeyMap with readable [id, name] tuple keys. + check("ItemConf multi-column index (TupleKeyMap)", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + + const m = item!.findAwardItemMap(); + for (const [key, values] of m) { + assert.equal(key.length, 2); + assert.equal(m.get(key), values); + assert.equal( + item!.findAwardItem(key[0] as number, key[1] as string), + values, + ); + } + // apple is keyed by (id=1, name="apple") + assert.deepEqual(m.get([1, "apple"])?.map((i) => i.id), [1]); + }); +} diff --git a/test/ts-tableau-loader/tests/load.test.ts b/test/ts-tableau-loader/tests/load.test.ts new file mode 100644 index 0000000..e71cbea --- /dev/null +++ b/test/ts-tableau-loader/tests/load.test.ts @@ -0,0 +1,149 @@ +// Load block: hub loading, filter, binary-format loading and patch loading. +// Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/load_test.go +// - C#: test/csharp-tableau-loader/tests/LoadTests.cs +// - C++: test/cpp-tableau-loader/tests/load_test.cpp +import { equals } from "@bufbuild/protobuf"; + +import { Hub } from "../tableau/hub.pc.js"; +import { Format, LoadMode } from "../tableau/load.pc.js"; +import { + PatchMergeConf, + PatchReplaceConf, + RecursivePatchConf, +} from "../tableau/patch_conf.pc.js"; +import { HeroConf } from "../tableau/hero_conf.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +import { + assert, + check, + confDir, + binDir, + patchConf, + patchConf2, + patchResult, + testdata, + join, + prepareHub, +} from "./harness.js"; + +export function run(): void { + // ---- Load ---- + + // Hub loads all messagers from a JSON directory. + check("hub.load(conf, JSON)", () => { + const hub = prepareHub(); + assert.ok(hub.getItemConf()); + assert.ok(hub.getActivityConf()); + }); + + // Hub typed accessor for an unloaded-by-filter messager returns undefined. + check("Hub filter", () => { + const filtered = new Hub({ filter: (n) => n === "ItemConf" }); + filtered.load(confDir, Format.JSON, { ignoreUnknownFields: true }); + assert.ok(filtered.getItemConf()); + assert.equal(filtered.getActivityConf(), undefined); + }); + + // ---- Bin ---- + + // Binary format load via a single messager. + check("HeroConf BIN load", () => { + const hero = new HeroConf(); + hero.load(binDir, Format.BIN); + assert.equal(hero.name(), "HeroConf"); + }); + + // BIN load from a non-existent directory throws (mirrors C#'s failure case). + check("HeroConf BIN load failure", () => { + const hero = new HeroConf(); + assert.throws(() => hero.load(join(testdata, "no-such-bin-dir"), Format.BIN)); + }); + + // ---- Patch ---- + + // Patch MERGE: main + two patch files merged in order. + check("PatchMergeConf MERGE", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Scalar overridden by the first patch. + assert.equal(pm.data().name, "orange"); + // itemMap key 999 contributed by the second patch (merge, not replace). + assert.ok(pm.get1(999)); + assert.ok(pm.get1(1)); + }); + + // LoadMode.ONLY_MAIN ignores the patch files. + check("PatchMergeConf ONLY_MAIN", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_MAIN, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + assert.equal(pm.data().name, "apple"); + assert.equal(pm.get1(999), undefined); + }); + + // PATCH_MERGE golden: conf + patchconf merged must equal the canonical + // patchresult, checked as a whole (mirrors Go/C#'s proto.Equal comparison). + check("RecursivePatchConf MERGE golden", () => { + const got = new RecursivePatchConf(); + got.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + + // The golden file is itself a RecursivePatchConf sheet (PATCH_MERGE), so load + // it with no patch sources to get the main file content verbatim. + const expected = new RecursivePatchConf(); + expected.load(patchResult, Format.JSON, { ignoreUnknownFields: true }); + + assert.ok( + equals(protoconf.RecursivePatchConfSchema, got.message(), expected.message()), + "patched RecursivePatchConf does not match the golden patchresult", + ); + }); + + // PATCH_REPLACE: the sheet-level option replaces the whole message with the + // last patch file, rather than merging field-by-field. + check("PatchReplaceConf PATCH_REPLACE", () => { + const pr = new PatchReplaceConf(); + pr.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + patchDirs: [patchConf], + }); + // Main file has name "apple" / priceList [10,100]; the patch fully replaces + // it with name "orange" / priceList [20,200] (replace, not append). + assert.equal(pr.data().name, "orange"); + assert.deepEqual(pr.data().priceList.map(Number), [20, 200]); + }); + + // LoadMode.ONLY_PATCH starts from an empty message and applies patches only. + check("PatchMergeConf ONLY_PATCH", () => { + const pm = new PatchMergeConf(); + pm.load(confDir, Format.JSON, { + ignoreUnknownFields: true, + mode: LoadMode.ONLY_PATCH, + patchPaths: [ + join(patchConf, "PatchMergeConf.json"), + join(patchConf2, "PatchMergeConf.json"), + ], + }); + // Name comes from the first patch (second patch carries no name field), and + // both itemMap entries come purely from the patches, not the main file. + assert.equal(pm.data().name, "orange"); + assert.ok(pm.get1(1)); + assert.ok(pm.get1(999)); + }); +} diff --git a/test/ts-tableau-loader/tests/ordered_map.test.ts b/test/ts-tableau-loader/tests/ordered_map.test.ts new file mode 100644 index 0000000..0231de2 --- /dev/null +++ b/test/ts-tableau-loader/tests/ordered_map.test.ts @@ -0,0 +1,64 @@ +// OrderedMap block: getOrderedMap traversal. Mirrors the same scenarios in: +// - Go: test/go-tableau-loader/ordered_map_test.go +// - C#: test/csharp-tableau-loader/tests/OrderedMapTests.cs +// - C++: test/cpp-tableau-loader/tests/ordered_map_test.cpp +import { assert, check, prepareHub } from "./harness.js"; + +export function run(): void { + // ---- ActivityConf: nested ordered map ---- + // getOrderedMap() exposes the full sorted tree; each node carries its value + // (.second) and the next-level sub-map (.first), and the leveled + // getOrderedMap1/2/3 getters descend it. + check("ActivityConf nested ordered map", () => { + const activity = prepareHub().getActivityConf(); + assert.ok(activity); + + // 1st level: bigint keys, ascending; node.second is the Activity value. + const top = activity!.getOrderedMap(); + assert.ok(top.size > 0); + let prev = -1n; + for (const key of top.keys()) { + assert.ok(key >= prev, `ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + assert.equal(top.get(100001n)?.second.activityName, "活动1"); + + // descending via .first equals the scoped getter (2nd level: chapter map). + const chapters = activity!.getOrderedMap1(100001n); + assert.equal(chapters, top.get(100001n)?.first); + assert.equal(chapters?.get(1)?.second.chapterName, "签到活动章1"); + + // 3rd level: section map, scoped to (activityId, chapterId). + const sections = activity!.getOrderedMap2(100001n, 1); + assert.equal(sections, chapters?.get(1)?.first); + + // 4th level (leaf): scalar map sectionRankMap["2001"] === 2, ascending keys. + const ranks = activity!.getOrderedMap3(100001n, 1, 2); + assert.equal(ranks, sections?.get(2)?.first); + assert.equal(ranks?.get(2001), 2); + let prevRank = -Infinity; + for (const key of ranks!.keys()) { + assert.ok(key >= prevRank, `leaf ordered map keys not ascending: ${key} after ${prevRank}`); + prevRank = key; + } + + // missing keys return undefined at every level. + assert.equal(activity!.getOrderedMap1(999n), undefined); + assert.equal(activity!.getOrderedMap3(100001n, 1, 999), undefined); + }); + + // ---- ItemConf: 1st-level ordered map ---- + check("ItemConf ordered map ascending", () => { + const item = prepareHub().getItemConf(); + assert.ok(item); + const orderedMap = item!.getOrderedMap(); + assert.ok(orderedMap.size > 0); + + // keys ascending by id + let prev = -Infinity; + for (const key of orderedMap.keys()) { + assert.ok(key >= prev, `ItemConf ordered map keys not ascending: ${key} after ${prev}`); + prev = key; + } + }); +} diff --git a/test/ts-tableau-loader/tests/smoke.ts b/test/ts-tableau-loader/tests/smoke.ts index d764246..2d429c2 100644 --- a/test/ts-tableau-loader/tests/smoke.ts +++ b/test/ts-tableau-loader/tests/smoke.ts @@ -1,171 +1,19 @@ -import { strict as assert } from "node:assert"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -import { equals } from "@bufbuild/protobuf"; - -import { Hub } from "../tableau/hub.pc.js"; -import { Format, LoadMode } from "../tableau/load.pc.js"; -import { - PatchMergeConf, - PatchReplaceConf, - RecursivePatchConf, -} from "../tableau/patch_conf.pc.js"; -import { HeroConf } from "../tableau/hero_conf.pc.js"; -import * as protoconf from "../tableau/barrel/protoconf.pc.js"; - -const here = dirname(fileURLToPath(import.meta.url)); -const testdata = join(here, "..", "..", "testdata"); -const confDir = join(testdata, "conf"); -const binDir = join(testdata, "bin"); -const patchConf = join(testdata, "patchconf"); -const patchConf2 = join(testdata, "patchconf2"); -const patchResult = join(testdata, "patchresult"); - -let passed = 0; -function check(name: string, fn: () => void): void { - fn(); - passed++; - console.log(` ok - ${name}`); -} - -// 1. Hub loads all messagers from a JSON directory. -const hub = new Hub(); -check("hub.load(conf, JSON)", () => { - hub.load(confDir, Format.JSON, { ignoreUnknownFields: true }); -}); - -// 2. ItemConf: first-level int-keyed map getter. -check("ItemConf map getter", () => { - const item = hub.getItemConf(); - assert.ok(item); - assert.equal(item!.name(), "ItemConf"); - assert.equal(item!.get1(1)?.name, "apple"); - assert.equal(item!.get1(0)?.name, "coin1"); - assert.equal(item!.get1(12345), undefined); -}); - -// 3. ActivityConf: 4-level nested map getter with a 64-bit (bigint) outer key. -check("ActivityConf nested map getters", () => { - const activity = hub.getActivityConf(); - assert.ok(activity); - assert.equal(activity!.get1(100001n)?.activityName, "活动1"); - assert.equal(activity!.get2(100001n, 1)?.chapterName, "签到活动章1"); - // sectionRankMap["2001"] === 2 - assert.equal(activity!.get4(100001n, 1, 2, 2001), 2); - assert.equal(activity!.get3(100001n, 1, 99), undefined); -}); - -// 4. ThemeConf: string-keyed map getter. -check("ThemeConf string-keyed map", () => { - const theme = hub.getThemeConf(); - assert.ok(theme); - assert.ok(Object.keys(theme!.data().themeMap).length > 0); -}); - -// 5. Hub typed accessor for an unloaded-by-filter messager returns undefined. -check("Hub filter", () => { - const filtered = new Hub({ filter: (n) => n === "ItemConf" }); - filtered.load(confDir, Format.JSON, { ignoreUnknownFields: true }); - assert.ok(filtered.getItemConf()); - assert.equal(filtered.getActivityConf(), undefined); -}); - -// 6. Binary format load via a single messager. -check("HeroConf BIN load", () => { - const hero = new HeroConf(); - hero.load(binDir, Format.BIN); - assert.equal(hero.name(), "HeroConf"); -}); - -// 7. Patch MERGE: main + two patch files merged in order. -check("PatchMergeConf MERGE", () => { - const pm = new PatchMergeConf(); - pm.load(confDir, Format.JSON, { - ignoreUnknownFields: true, - patchPaths: [ - join(patchConf, "PatchMergeConf.json"), - join(patchConf2, "PatchMergeConf.json"), - ], - }); - // Scalar overridden by the first patch. - assert.equal(pm.data().name, "orange"); - // itemMap key 999 contributed by the second patch (merge, not replace). - assert.ok(pm.get1(999)); - assert.ok(pm.get1(1)); -}); - -// 8. LoadMode.ONLY_MAIN ignores the patch files. -check("PatchMergeConf ONLY_MAIN", () => { - const pm = new PatchMergeConf(); - pm.load(confDir, Format.JSON, { - ignoreUnknownFields: true, - mode: LoadMode.ONLY_MAIN, - patchPaths: [ - join(patchConf, "PatchMergeConf.json"), - join(patchConf2, "PatchMergeConf.json"), - ], - }); - assert.equal(pm.data().name, "apple"); - assert.equal(pm.get1(999), undefined); -}); - -// 9. PATCH_MERGE golden: conf + patchconf merged must equal the canonical -// patchresult, checked as a whole (mirrors Go/C#'s proto.Equal comparison). -check("RecursivePatchConf MERGE golden", () => { - const got = new RecursivePatchConf(); - got.load(confDir, Format.JSON, { - ignoreUnknownFields: true, - patchDirs: [patchConf], - }); - - // The golden file is itself a RecursivePatchConf sheet (PATCH_MERGE), so load - // it with no patch sources to get the main file content verbatim. - const expected = new RecursivePatchConf(); - expected.load(patchResult, Format.JSON, { ignoreUnknownFields: true }); - - assert.ok( - equals(protoconf.RecursivePatchConfSchema, got.message(), expected.message()), - "patched RecursivePatchConf does not match the golden patchresult", - ); -}); - -// 10. PATCH_REPLACE: the sheet-level option replaces the whole message with the -// last patch file, rather than merging field-by-field. -check("PatchReplaceConf PATCH_REPLACE", () => { - const pr = new PatchReplaceConf(); - pr.load(confDir, Format.JSON, { - ignoreUnknownFields: true, - patchDirs: [patchConf], - }); - // Main file has name "apple" / priceList [10,100]; the patch fully replaces - // it with name "orange" / priceList [20,200] (replace, not append). - assert.equal(pr.data().name, "orange"); - assert.deepEqual(pr.data().priceList.map(Number), [20, 200]); -}); - -// 11. LoadMode.ONLY_PATCH starts from an empty message and applies patches only. -check("PatchMergeConf ONLY_PATCH", () => { - const pm = new PatchMergeConf(); - pm.load(confDir, Format.JSON, { - ignoreUnknownFields: true, - mode: LoadMode.ONLY_PATCH, - patchPaths: [ - join(patchConf, "PatchMergeConf.json"), - join(patchConf2, "PatchMergeConf.json"), - ], - }); - // Name comes from the first patch (second patch carries no name field), and - // both itemMap entries come purely from the patches, not the main file. - assert.equal(pm.data().name, "orange"); - assert.ok(pm.get1(1)); - assert.ok(pm.get1(999)); -}); - -// 12. BIN load from a non-existent directory throws (mirrors C#'s failure case). -check("HeroConf BIN load failure", () => { - const hero = new HeroConf(); - assert.throws(() => hero.load(join(testdata, "no-such-bin-dir"), Format.BIN)); -}); - -console.log(`\nAll ${passed} smoke checks passed.`); +// Smoke-test entry point. Runs the four test blocks in order: +// Load -> Get -> OrderedMap -> Index +// Each block lives in its own file and shares the harness in ./harness.ts. +import { run as runLoad } from "./load.test.js"; +import { run as runGet } from "./get.test.js"; +import { run as runOrderedMap } from "./ordered_map.test.js"; +import { run as runIndex } from "./index.test.js"; +import { passedCount } from "./harness.js"; + +console.log("# Load"); +runLoad(); +console.log("# Get"); +runGet(); +console.log("# OrderedMap"); +runOrderedMap(); +console.log("# Index"); +runIndex(); + +console.log(`\nAll ${passedCount()} smoke checks passed.`); From 44a30a4d89656006f32cd840c6a9f183fec7d2a5 Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Mon, 15 Jun 2026 11:44:07 +0800 Subject: [PATCH 4/7] ci(testing-ts): use env variable for node version --- .github/workflows/testing-ts.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/testing-ts.yml b/.github/workflows/testing-ts.yml index f6ed922..48139d4 100644 --- a/.github/workflows/testing-ts.yml +++ b/.github/workflows/testing-ts.yml @@ -18,11 +18,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - # Pin to the active LTS line (Node 20), matching NODE_VERSION used by - # the devcontainer and make.py for local/CI parity. - node-version: ["20"] - name: test (${{ matrix.os }}, node ${{ matrix.node-version }}) + name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 10 @@ -36,9 +33,6 @@ jobs: uses: ./.github/actions/load-versions - name: Install Go - # buf-generate runs the Go protoc plugins (incl. - # protoc-gen-ts-tableau-loader) via `go run`, so Go is required even - # though the harness under test is TypeScript. uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -48,7 +42,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: test/ts-tableau-loader/package-lock.json @@ -61,7 +55,4 @@ jobs: run: python3 make.py setup --lang go - name: Test - # `make.py test --lang ts` runs `buf generate` (Go plugins + protobuf-es - # remote plugin), `npm ci`, `npm run check` (tsc --noEmit type check), - # then `npm run smoke` (tsx tests/smoke.ts). run: python3 make.py test --lang ts From 6e4519849b3c2343507dee0ab30c39e216ef8566 Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Mon, 15 Jun 2026 20:26:58 +0800 Subject: [PATCH 5/7] test: align cross-language shared fixtures and tidy TS custom messager - ts: move custom_item_conf.ts out of tests/ into a dedicated custom/ dir (matches C# custom/, Go customconf/), update tsconfig include and import path - csharp: drop TaskConf filter from shared HubFixture so it is a full load equivalent to Go/C++/TS; cover HubOptions.Filter via an isolated Hub_Filter_LoadsOnlyMatchingMessagers test --- .../custom/custom_item_conf.ts | 52 +++++++++++++++++++ test/ts-tableau-loader/tests/index.test.ts | 2 + test/ts-tableau-loader/tests/load.test.ts | 32 +++++++++++- test/ts-tableau-loader/tsconfig.json | 2 +- 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 test/ts-tableau-loader/custom/custom_item_conf.ts diff --git a/test/ts-tableau-loader/custom/custom_item_conf.ts b/test/ts-tableau-loader/custom/custom_item_conf.ts new file mode 100644 index 0000000..00118a5 --- /dev/null +++ b/test/ts-tableau-loader/custom/custom_item_conf.ts @@ -0,0 +1,52 @@ +// Hand-written (custom) messager. +// +// It mirrors the canonical custom-messager pattern of the other-language +// loaders, demonstrating the cross-messager `processAfterLoadAll` extension +// point: +// - Go: test/go-tableau-loader/customconf/custom_item_conf.go +// - C#: test/csharp-tableau-loader/custom/CustomItemConf.cs +// - C++: test/cpp-tableau-loader/src/hub/custom/item/custom_item_conf.{h,cpp} +// +// Unlike generated messagers, it carries no config file of its own; its `load` +// is a no-op and it derives its data from another messager (ItemConf) once all +// messagers have finished loading. +import { Messager } from "../tableau/messager.pc.js"; +import { Format, type MessagerOptions } from "../tableau/load.pc.js"; +import type { Hub } from "../tableau/hub.pc.js"; +import * as protoconf from "../tableau/barrel/protoconf.pc.js"; + +export const CustomItemConfName = "CustomItemConf"; + +/** + * CustomItemConf is a hand-written messager. It has no backing file and instead + * resolves a "special" item from ItemConf during processAfterLoadAll. + */ +export class CustomItemConf extends Messager { + #specialItemConf: protoconf.ItemConf_Item | undefined; + + /** name returns the CustomItemConf's message name. */ + name(): string { + return CustomItemConfName; + } + + /** load is a no-op: this messager has no file of its own. */ + load(_dir: string, _fmt: Format, _options?: MessagerOptions): void {} + + /** processAfterLoadAll consumes ItemConf's data after all messagers load. */ + override processAfterLoadAll(hub: Hub): void { + const itemConf = hub.getItemConf(); + if (!itemConf) { + throw new Error("hub get ItemConf failed"); + } + const conf = itemConf.get1(1); + if (!conf) { + throw new Error("hub get item 1 failed"); + } + this.#specialItemConf = conf; + } + + /** getSpecialItemName returns the resolved special item's name. */ + getSpecialItemName(): string { + return this.#specialItemConf?.name ?? ""; + } +} diff --git a/test/ts-tableau-loader/tests/index.test.ts b/test/ts-tableau-loader/tests/index.test.ts index d907468..08b293c 100644 --- a/test/ts-tableau-loader/tests/index.test.ts +++ b/test/ts-tableau-loader/tests/index.test.ts @@ -114,5 +114,7 @@ export function run(): void { } // apple is keyed by (id=1, name="apple") assert.deepEqual(m.get([1, "apple"])?.map((i) => i.id), [1]); + // findFirst* variant returns the first match for the same key. + assert.equal(item!.findFirstAwardItem(1, "apple")?.id, 1); }); } diff --git a/test/ts-tableau-loader/tests/load.test.ts b/test/ts-tableau-loader/tests/load.test.ts index e71cbea..bd13c81 100644 --- a/test/ts-tableau-loader/tests/load.test.ts +++ b/test/ts-tableau-loader/tests/load.test.ts @@ -5,7 +5,7 @@ // - C++: test/cpp-tableau-loader/tests/load_test.cpp import { equals } from "@bufbuild/protobuf"; -import { Hub } from "../tableau/hub.pc.js"; +import { Hub, Registry } from "../tableau/hub.pc.js"; import { Format, LoadMode } from "../tableau/load.pc.js"; import { PatchMergeConf, @@ -15,6 +15,8 @@ import { import { HeroConf } from "../tableau/hero_conf.pc.js"; import * as protoconf from "../tableau/barrel/protoconf.pc.js"; +import { CustomItemConf, CustomItemConfName } from "../custom/custom_item_conf.js"; + import { assert, check, @@ -28,6 +30,11 @@ import { prepareHub, } from "./harness.js"; +// Register the hand-written CustomItemConf before any hub is built, so the +// shared hub (and any hub loaded afterwards) includes it. Mirrors Go's init() +// Register, C#'s HubFixture registration and C++'s InitCustomMessager. +Registry.register(CustomItemConf); + export function run(): void { // ---- Load ---- @@ -38,6 +45,21 @@ export function run(): void { assert.ok(hub.getActivityConf()); }); + // ---- CustomConf ---- + + // CustomItemConf is a hand-written messager: it has no file of its own and + // resolves a "special" item from ItemConf via processAfterLoadAll. Mirrors + // Go's Test_CustomItemConf and C#/C++'s + // CustomItemConf_ProcessAfterLoadAll_ResolvesSpecialItem. + check("CustomItemConf processAfterLoadAll", () => { + const hub = prepareHub(); + const custom = hub.getMessager(CustomItemConfName) as CustomItemConf | undefined; + assert.ok(custom); + // ItemConf item 1 is "apple", so the resolved special item name is non-empty. + assert.notEqual(custom!.getSpecialItemName(), ""); + assert.equal(custom!.getSpecialItemName(), "apple"); + }); + // Hub typed accessor for an unloaded-by-filter messager returns undefined. check("Hub filter", () => { const filtered = new Hub({ filter: (n) => n === "ItemConf" }); @@ -78,6 +100,14 @@ export function run(): void { // itemMap key 999 contributed by the second patch (merge, not replace). assert.ok(pm.get1(999)); assert.ok(pm.get1(1)); + // itemMap merges field-by-field, so the main file's key 2 survives. + assert.ok(pm.get1(2)); + // replaceItemMap has the field-level PATCH_REPLACE option: the last patch + // that carries it fully replaces the field. patchconf2 provides {1, 999}, + // so key 999 is present while the main file's key 2 is dropped (replace, + // not merge). Mirrors Go/C#/C++'s ReplaceItemMap[999] assertion. + assert.ok(pm.data().replaceItemMap[999]); + assert.equal(pm.data().replaceItemMap[2], undefined); }); // LoadMode.ONLY_MAIN ignores the patch files. diff --git a/test/ts-tableau-loader/tsconfig.json b/test/ts-tableau-loader/tsconfig.json index fc607f5..d47a5b2 100644 --- a/test/ts-tableau-loader/tsconfig.json +++ b/test/ts-tableau-loader/tsconfig.json @@ -9,5 +9,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["tableau", "protoconf", "tests"] + "include": ["tableau", "protoconf", "custom", "tests"] } From 618c8ecf5116219c2e7c224ad97dba1eb810cac9 Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Tue, 16 Jun 2026 16:16:31 +0800 Subject: [PATCH 6/7] refactor: extract shared codegen helpers into internal/genhelper and dedup across loaders - add internal/genhelper with shared file-header (ProtocVersion/header) and map-key (MapKey/MapKeySlice) helpers - replace per-loader duplicated helper code (cpp/csharp/go/ts) by reusing the shared genhelper package - ts: extract barrel logic into barrel.go; add reserved-keyword handling (helper/keyword.go) - csharp: drop unused gen/messagerName params from genMessage/genMapGetters/generateFileContent - remove obsolete cmd/protoc-gen-go-tableau-loader/helper.go --- cmd/protoc-gen-ts-tableau-loader/barrel.go | 120 ++++++++++++++++ .../helper/helper.go | 92 +++--------- .../helper/keyword.go | 23 +++ cmd/protoc-gen-ts-tableau-loader/index.go | 33 ++--- cmd/protoc-gen-ts-tableau-loader/messager.go | 131 +----------------- 5 files changed, 183 insertions(+), 216 deletions(-) create mode 100644 cmd/protoc-gen-ts-tableau-loader/barrel.go create mode 100644 cmd/protoc-gen-ts-tableau-loader/helper/keyword.go diff --git a/cmd/protoc-gen-ts-tableau-loader/barrel.go b/cmd/protoc-gen-ts-tableau-loader/barrel.go new file mode 100644 index 0000000..bbdbb6b --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/barrel.go @@ -0,0 +1,120 @@ +package main + +import ( + "sort" + "strings" + + "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// pbPackages tracks the protobuf packages a single loader file imports and +// emits one namespace import per package, pointing at that package's barrel +// module under the barrel/ subdir: `import * as from +// "barrel/.pc.js"`. The alias is the proto package name (dots -> +// underscores), so generated loaders qualify types exactly like the loaders of +// other languages (e.g. protoconf.ItemConf, base.Hero), keeping cross-language +// naming consistent. +type pbPackages struct { + rel string // relative path prefix from this loader to the barrel dir + order []string // referenced package names, in first-seen order + seen map[string]bool // deduplication set +} + +// newPBPackages creates a registry for a loader whose source-relative output +// prefix is loaderPrefix (e.g. "hero_conf" or "sub/foo"). The number of path +// segments determines how many "../" are needed to reach the loader output +// root; barrel modules live in the barrel/ subdir under that root. +func newPBPackages(loaderPrefix string) *pbPackages { + rel := "./" + if depth := strings.Count(loaderPrefix, "/"); depth > 0 { + rel = strings.Repeat("../", depth) + } + return &pbPackages{rel: rel, seen: map[string]bool{}} +} + +// add registers a proto package (idempotent), preserving first-seen order. +func (p *pbPackages) add(pkg string) { + if p.seen[pkg] { + return + } + p.seen[pkg] = true + p.order = append(p.order, pkg) +} + +// aliasOf returns the namespace alias for a descriptor's owning package. +func (p *pbPackages) aliasOf(d protoreflect.Descriptor) string { + return helper.PackageAlias(string(d.ParentFile().Package())) +} + +// emit writes one namespace import statement per package, in registration order. +func (p *pbPackages) emit(g *protogen.GeneratedFile) { + for _, pkg := range p.order { + alias := helper.PackageAlias(pkg) + g.P(`import * as `, alias, ` from "`, p.rel, `barrel/`, alias, `.pc.js";`) + } +} + +// moduleOf returns the protobuf-es base module specifier for a descriptor's +// parent file (e.g. "../protoconf/base/base_pb.js"), relative to the loader +// output root. +func moduleOf(d protoreflect.Descriptor) string { + return pbImportPath + "/" + strings.TrimSuffix(d.ParentFile().Path(), ".proto") + "_pb.js" +} + +// barrelRegistry collects, per proto package, all generated files (used to fill +// a package's barrel) and tracks which packages are actually referenced by some +// loader (so only those packages get a barrel emitted). +type barrelRegistry struct { + filesByPkg map[string][]*protogen.File // package -> all files of that package + referenced []string // referenced packages, in first-seen order + seen map[string]bool // deduplication set for referenced +} + +// newBarrelRegistry groups every file known to the generator by proto package. +func newBarrelRegistry(gen *protogen.Plugin) *barrelRegistry { + r := &barrelRegistry{filesByPkg: map[string][]*protogen.File{}, seen: map[string]bool{}} + for _, f := range gen.Files { + pkg := string(f.Desc.Package()) + r.filesByPkg[pkg] = append(r.filesByPkg[pkg], f) + } + return r +} + +// markReferenced records that a package is imported by some loader. +func (r *barrelRegistry) markReferenced(pkg string) { + if r.seen[pkg] { + return + } + r.seen[pkg] = true + r.referenced = append(r.referenced, pkg) +} + +// generateBarrels emits one barrel module per referenced proto package into the +// barrel/ subdir of the loader output root. Each barrel re-exports every +// protobuf-es generated module of that package via `export *`, so a loader can +// import the whole package under a single namespace alias. This is +// collision-free because protobuf guarantees fully-qualified names are unique +// within a package, so the wildcard re-exports never clash. Because the barrel +// sits one level under the loader output root, an extra "../" is prepended to +// each module path (which moduleOf computes relative to that root). +func generateBarrels(gen *protogen.Plugin, reg *barrelRegistry) { + for _, pkg := range reg.referenced { + alias := helper.PackageAlias(pkg) + g := gen.NewGeneratedFile("barrel/"+alias+".pc.ts", "") + helper.GenerateFileHeader(gen, nil, g, version) + g.P(`// Barrel for proto package "`, pkg, `": re-exports every protobuf-es`) + g.P("// generated module of this package, so loaders import the whole package") + g.P("// under one namespace alias (import * as ", alias, `), matching the`) + g.P("// package-qualified naming used by loaders of other languages.") + g.P() + files := append([]*protogen.File(nil), reg.filesByPkg[pkg]...) + sort.Slice(files, func(i, j int) bool { + return files[i].Desc.Path() < files[j].Desc.Path() + }) + for _, f := range files { + g.P(`export * from "../`, moduleOf(f.Desc), `";`) + } + } +} diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go index 3e084e1..a915d26 100644 --- a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go +++ b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/iancoleman/strcase" + "github.com/tableauio/loader/internal/genhelper" "github.com/tableauio/tableau/proto/tableaupb" "google.golang.org/protobuf/compiler/protogen" "google.golang.org/protobuf/proto" @@ -19,37 +20,12 @@ func GenerateFileHeader(gen *protogen.Plugin, file *protogen.File, g *protogen.G g.P("// @generated by protoc-gen-ts-tableau-loader. DO NOT EDIT.") g.P("// versions:") g.P("// - protoc-gen-ts-tableau-loader v", version) - g.P("// - protoc ", protocVersion(gen)) - if file != nil { - if file.Proto.GetOptions().GetDeprecated() { - g.P("// ", file.Desc.Path(), " is a deprecated file.") - } else { - g.P("// source: ", file.Desc.Path()) - } - } + g.P("// - protoc ", genhelper.ProtocVersion(gen)) + genhelper.GenerateSourcePath(file, g) g.P("/* eslint-disable */") g.P() } -// protocVersion returns the protoc compiler version string (e.g. "v3.19.3"). -func protocVersion(gen *protogen.Plugin) string { - v := gen.Request.GetCompilerVersion() - if v == nil { - return "(unknown)" - } - var suffix string - if s := v.GetSuffix(); s != "" { - suffix = "-" + s - } - return fmt.Sprintf("v%d.%d.%d%s", v.GetMajor(), v.GetMinor(), v.GetPatch(), suffix) -} - -// MessagerName returns the TypeScript class name for a worksheet message. -// Worksheet messages are always top-level, so this is just the short name. -func MessagerName(md protoreflect.MessageDescriptor) string { - return string(md.Name()) -} - // LocalTypeName returns the protobuf-es local type name for a message or enum, // which joins the nested type path with underscores after stripping the package // (e.g. "protoconf.ThemeConf.Theme" -> "ThemeConf_Theme", @@ -106,20 +82,15 @@ func ScalarTSType(kind protoreflect.Kind) (string, bool) { } // MapKey represents a single key component of a (possibly nested) map getter. +// It embeds the cross-language genhelper.MapKey (Type/Name/Fd/...) so its base +// structure stays aligned with the Go/C++/C# loaders, and adds the +// TypeScript-specific NeedToString flag. type MapKey struct { - // ParamType is the TypeScript getter parameter type (number/bigint/string/boolean). - ParamType string - // IdxKind is the TypeScript index-signature kind of the protobuf-es map - // object (number or string), used to build indexed-access return types. - IdxKind string - // Name is the getter parameter name (deduplicated across nested levels). - Name string + genhelper.MapKey // NeedToString reports whether the parameter must be stringified to index - // the underlying map object (true for 64-bit / bool keys). + // the underlying protobuf-es map object (true for 64-bit / bool keys, whose + // JS object keys are always stored as strings). NeedToString bool - // Fd is the map field descriptor this key belongs to (used to derive the - // leveled-container key alias name; may be nil for non-leveled keys). - Fd protoreflect.FieldDescriptor } // IndexExpr returns the expression used to index the underlying map object. @@ -152,7 +123,7 @@ func (s MapKeySlice) AddMapKey(newKey MapKey) MapKeySlice { func (s MapKeySlice) GenGetParams() string { var params []string for _, key := range s { - params = append(params, key.Name+": "+key.ParamType) + params = append(params, key.Name+": "+key.Type) } return strings.Join(params, ", ") } @@ -166,33 +137,28 @@ func (s MapKeySlice) GenGetArguments() string { return strings.Join(args, ", ") } -// ParseMapKey returns the MapKey metadata (param type, index kind, stringify -// flag) for a map field's key descriptor (fd must be a map key descriptor). +// ParseMapKey returns the MapKey metadata (param type, stringify flag) for a +// map field's key descriptor (fd must be a map key descriptor). func ParseMapKey(keyFd protoreflect.FieldDescriptor, name string) MapKey { - key := MapKey{Name: name} + key := MapKey{MapKey: genhelper.MapKey{Name: name}} switch keyFd.Kind() { case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, protoreflect.Uint32Kind, protoreflect.Fixed32Kind: - key.ParamType = "number" - key.IdxKind = "number" + key.Type = "number" key.NeedToString = false case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind: - key.ParamType = "bigint" - key.IdxKind = "string" + key.Type = "bigint" key.NeedToString = true case protoreflect.BoolKind: - key.ParamType = "boolean" - key.IdxKind = "string" + key.Type = "boolean" key.NeedToString = true case protoreflect.StringKind: - key.ParamType = "string" - key.IdxKind = "string" + key.Type = "string" key.NeedToString = false default: // Map keys can only be integral, bool, or string. - key.ParamType = "string" - key.IdxKind = "string" + key.Type = "string" key.NeedToString = false } return key @@ -322,28 +288,6 @@ func TSEmptyValue(fd protoreflect.FieldDescriptor) string { } } -// tsReservedWords are TypeScript/JavaScript reserved words that cannot be used -// as bare identifiers for function parameters. -var tsReservedWords = map[string]bool{ - "break": true, "case": true, "catch": true, "class": true, "const": true, - "continue": true, "debugger": true, "default": true, "delete": true, "do": true, - "else": true, "enum": true, "export": true, "extends": true, "false": true, - "finally": true, "for": true, "function": true, "if": true, "import": true, - "in": true, "instanceof": true, "new": true, "null": true, "return": true, - "super": true, "switch": true, "this": true, "throw": true, "true": true, - "try": true, "typeof": true, "var": true, "void": true, "while": true, - "with": true, "let": true, "static": true, "yield": true, "await": true, -} - -// escapeIdentifier escapes a TypeScript reserved word by appending an -// underscore, producing a valid identifier. -func escapeIdentifier(name string) string { - if tsReservedWords[name] { - return name + "_" - } - return name -} - // Indent returns a string of 2*depth spaces, used for indenting generated // TypeScript code blocks at the specified nesting depth. func Indent(depth int) string { diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go b/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go new file mode 100644 index 0000000..d903a8a --- /dev/null +++ b/cmd/protoc-gen-ts-tableau-loader/helper/keyword.go @@ -0,0 +1,23 @@ +package helper + +// tsReservedWords are TypeScript/JavaScript reserved words that cannot be used +// as bare identifiers for function parameters. +var tsReservedWords = map[string]bool{ + "break": true, "case": true, "catch": true, "class": true, "const": true, + "continue": true, "debugger": true, "default": true, "delete": true, "do": true, + "else": true, "enum": true, "export": true, "extends": true, "false": true, + "finally": true, "for": true, "function": true, "if": true, "import": true, + "in": true, "instanceof": true, "new": true, "null": true, "return": true, + "super": true, "switch": true, "this": true, "throw": true, "true": true, + "try": true, "typeof": true, "var": true, "void": true, "while": true, + "with": true, "let": true, "static": true, "yield": true, "await": true, +} + +// escapeIdentifier escapes a TypeScript reserved word by appending an +// underscore, producing a valid identifier. +func escapeIdentifier(name string) string { + if tsReservedWords[name] { + return name + "_" + } + return name +} diff --git a/cmd/protoc-gen-ts-tableau-loader/index.go b/cmd/protoc-gen-ts-tableau-loader/index.go index 719fc12..0a2b104 100644 --- a/cmd/protoc-gen-ts-tableau-loader/index.go +++ b/cmd/protoc-gen-ts-tableau-loader/index.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" + "github.com/tableauio/loader/internal/genhelper" "github.com/tableauio/loader/internal/index" "github.com/tableauio/loader/internal/loadutil" "github.com/tableauio/loader/internal/options" @@ -52,11 +53,9 @@ func (x *indexGen) initLevelKeys() { break } paramName := helper.ParseMapFieldNameAsFuncParam(fd) - x.keys = x.keys.AddMapKey(helper.MapKey{ - ParamType: helper.ParseMapKey(fd.MapKey(), paramName).ParamType, - Name: paramName, - Fd: fd, - }) + key := helper.ParseMapKey(fd.MapKey(), paramName) + key.Fd = fd + x.keys = x.keys.AddMapKey(key) } } } @@ -147,8 +146,10 @@ func (x *indexGen) params(idx *index.LevelIndex) helper.MapKeySlice { var keys helper.MapKeySlice for _, field := range idx.ColFields { keys = keys.AddMapKey(helper.MapKey{ - ParamType: x.keyTSType(field.FD), - Name: helper.IndexFieldNameAsFuncParam(field.FD), + MapKey: genhelper.MapKey{ + Type: x.keyTSType(field.FD), + Name: helper.IndexFieldNameAsFuncParam(field.FD), + }, }) } return keys @@ -193,7 +194,7 @@ func (x *indexGen) aliasName(idx *index.LevelIndex, ordered bool) string { // ItemConf.Index_AwardItemMap), so field declarations and finder signatures // reference the readable named type rather than an inline Map/TupleKeyMap. func (x *indexGen) indexType(idx *index.LevelIndex, ordered bool) string { - return helper.MessagerName(x.message.Desc) + "." + x.aliasName(idx, ordered) + return string(x.message.Desc.Name()) + "." + x.aliasName(idx, ordered) } // keyAliasName returns the bare composite-key tuple alias name for a @@ -210,7 +211,7 @@ func (x *indexGen) keyAliasName(idx *index.LevelIndex, ordered bool) string { // ItemConf.Index_AwardItemKey), used to explicitly parameterize TupleKeyMap at // its (invariant-in-K) construction sites. func (x *indexGen) keyAliasType(idx *index.LevelIndex, ordered bool) string { - return helper.MessagerName(x.message.Desc) + "." + x.keyAliasName(idx, ordered) + return string(x.message.Desc.Name()) + "." + x.keyAliasName(idx, ordered) } // keyTupleType returns the labeled readonly tuple type of a multi-column @@ -230,7 +231,7 @@ func (x *indexGen) keyTupleType(idx *index.LevelIndex) string { func (x *indexGen) upperKeyTupleType(i int) string { var parts []string for _, k := range x.keys[:i] { - parts = append(parts, k.Name+": "+k.ParamType) + parts = append(parts, k.Name+": "+k.Type) } return "readonly [" + strings.Join(parts, ", ") + "]" } @@ -248,7 +249,7 @@ func (x *indexGen) upperKeyAliasName(i int) string { // (e.g. ActivityConf.LevelIndex_Activity_ChapterKey), used to type and // construct the i>=2 leveled TupleKeyMap containers. func (x *indexGen) upperKeyAliasType(i int) string { - return helper.MessagerName(x.message.Desc) + "." + x.upperKeyAliasName(i) + return string(x.message.Desc.Name()) + "." + x.upperKeyAliasName(i) } // newLeafContainer returns the construction expression for a multi-column @@ -276,7 +277,7 @@ func (x *indexGen) orderedMapValueAliasOf(mapFd protoreflect.FieldDescriptor) st // orderedMapTypeOf returns the messager-qualified ordered-map alias for a map // field (e.g. ActivityConf.OrderedMap_Activity_ChapterMap). func (x *indexGen) orderedMapTypeOf(mapFd protoreflect.FieldDescriptor) string { - return helper.MessagerName(x.message.Desc) + "." + x.orderedMapAliasOf(mapFd) + return string(x.message.Desc.Name()) + "." + x.orderedMapAliasOf(mapFd) } // orderedMapType returns the messager-qualified top-level (1st-level) ordered-map @@ -302,7 +303,7 @@ func (x *indexGen) genOrderedMapAliases(md protoreflect.MessageDescriptor, depth if fd.MapValue().Kind() == protoreflect.MessageKind { x.genOrderedMapAliases(fd.MapValue().Message(), depth+1) } - k := helper.ParseMapKey(fd.MapKey(), "").ParamType + k := helper.ParseMapKey(fd.MapKey(), "").Type alias := x.orderedMapAliasOf(fd) ordinal := loadutil.Ordinal(depth) if nextFd != nil { @@ -327,7 +328,7 @@ func (x *indexGen) GenTypeAliases() { if !x.NeedGenerate() { return } - name := helper.MessagerName(x.message.Desc) + name := string(x.message.Desc.Name()) x.g.P() x.g.P("// Type aliases for the index / ordered index / ordered map containers,") x.g.P("// mirroring the named container types of the other-language loaders so the") @@ -440,7 +441,7 @@ func (x *indexGen) genContainerDecls(lm *index.LevelMessage, idx *index.LevelInd // the call sites. for i := 1; i < lm.LeveledContainerDepth(); i++ { if i == 1 { - x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": Map<", x.keys[0].ParamType, ", ", at, "> = new Map();") + x.g.P(helper.Indent(1), x.containerField(idx, ordered, i), ": Map<", x.keys[0].Type, ", ", at, "> = new Map();") } else { ut := x.upperKeyAliasType(i) // The field carries the full TupleKeyMap annotation, so the @@ -506,7 +507,7 @@ func (x *indexGen) genOrderedMapLoaderLevel(md protoreflect.MessageDescriptor, d // mapKeyConv converts a string object-key (from Object.entries) into the proper // map key type. func (x *indexGen) mapKeyConv(keyFd protoreflect.FieldDescriptor, v string) string { - switch helper.ParseMapKey(keyFd, "").ParamType { + switch helper.ParseMapKey(keyFd, "").Type { case "number": return "Number(" + v + ")" case "bigint": diff --git a/cmd/protoc-gen-ts-tableau-loader/messager.go b/cmd/protoc-gen-ts-tableau-loader/messager.go index f633fd1..9ba6fa5 100644 --- a/cmd/protoc-gen-ts-tableau-loader/messager.go +++ b/cmd/protoc-gen-ts-tableau-loader/messager.go @@ -2,26 +2,15 @@ package main import ( "fmt" - "sort" - "strings" "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" "github.com/tableauio/loader/internal/index" "github.com/tableauio/loader/internal/loadutil" - "github.com/tableauio/tableau/proto/tableaupb" + "github.com/tableauio/loader/internal/options" "google.golang.org/protobuf/compiler/protogen" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" - "google.golang.org/protobuf/types/descriptorpb" ) -// isWorksheet reports whether a message is a tableau worksheet. -func isWorksheet(message *protogen.Message) bool { - opts := message.Desc.Options().(*descriptorpb.MessageOptions) - worksheet := proto.GetExtension(opts, tableaupb.E_Worksheet).(*tableaupb.WorksheetOptions) - return worksheet != nil -} - // generateMessager generates a loader file corresponding to the protobuf file. // Each wrapped class extends the Messager base class. func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegistry) { @@ -31,7 +20,7 @@ func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegi var messagers []*protogen.Message for _, message := range file.Messages { - if isWorksheet(message) { + if options.IsWorksheet(message.Desc) { messagers = append(messagers, message) } } @@ -94,14 +83,14 @@ func generateMessager(gen *protogen.Plugin, file *protogen.File, reg *barrelRegi if i > 0 { g.P() } - genMessage(gen, g, message, descriptors[message], pkgs) + genMessage(g, message, descriptors[message], pkgs) } } // genMessage generates a single messager class definition. -func genMessage(gen *protogen.Plugin, g *protogen.GeneratedFile, message *protogen.Message, descriptor *index.IndexDescriptor, pkgs *pbPackages) { +func genMessage(g *protogen.GeneratedFile, message *protogen.Message, descriptor *index.IndexDescriptor, pkgs *pbPackages) { md := message.Desc - name := helper.MessagerName(md) + name := string(md.Name()) alias := pkgs.aliasOf(md) schema := alias + "." + helper.LocalSchemaName(md) // runtime schema value dataType := alias + "." + helper.LocalTypeName(md) // message type @@ -226,60 +215,6 @@ func mapValueType(fd protoreflect.FieldDescriptor, pkgs *pbPackages) string { } } -// pbPackages tracks the protobuf packages a single loader file imports and -// emits one namespace import per package, pointing at that package's barrel -// module under the barrel/ subdir: `import * as from -// "barrel/.pc.js"`. The alias is the proto package name (dots -> -// underscores), so generated loaders qualify types exactly like the loaders of -// other languages (e.g. protoconf.ItemConf, base.Hero), keeping cross-language -// naming consistent. -type pbPackages struct { - rel string // relative path prefix from this loader to the barrel dir - order []string // referenced package names, in first-seen order - seen map[string]bool // deduplication set -} - -// newPBPackages creates a registry for a loader whose source-relative output -// prefix is loaderPrefix (e.g. "hero_conf" or "sub/foo"). The number of path -// segments determines how many "../" are needed to reach the loader output -// root; barrel modules live in the barrel/ subdir under that root. -func newPBPackages(loaderPrefix string) *pbPackages { - rel := "./" - if depth := strings.Count(loaderPrefix, "/"); depth > 0 { - rel = strings.Repeat("../", depth) - } - return &pbPackages{rel: rel, seen: map[string]bool{}} -} - -// add registers a proto package (idempotent), preserving first-seen order. -func (p *pbPackages) add(pkg string) { - if p.seen[pkg] { - return - } - p.seen[pkg] = true - p.order = append(p.order, pkg) -} - -// aliasOf returns the namespace alias for a descriptor's owning package. -func (p *pbPackages) aliasOf(d protoreflect.Descriptor) string { - return helper.PackageAlias(string(d.ParentFile().Package())) -} - -// emit writes one namespace import statement per package, in registration order. -func (p *pbPackages) emit(g *protogen.GeneratedFile) { - for _, pkg := range p.order { - alias := helper.PackageAlias(pkg) - g.P(`import * as `, alias, ` from "`, p.rel, `barrel/`, alias, `.pc.js";`) - } -} - -// moduleOf returns the protobuf-es base module specifier for a descriptor's -// parent file (e.g. "../protoconf/base/base_pb.js"), relative to the loader -// output root. -func moduleOf(d protoreflect.Descriptor) string { - return pbImportPath + "/" + strings.TrimSuffix(d.ParentFile().Path(), ".proto") + "_pb.js" -} - // collectValuePackages walks the same first-map-field chain as genMapGetters and // registers the owning package of every message/enum map value referenced by a // getter (including nested messages and those from other proto files), so each @@ -300,59 +235,3 @@ func collectValuePackages(md protoreflect.MessageDescriptor, pkgs *pbPackages) { break } } - -// barrelRegistry collects, per proto package, all generated files (used to fill -// a package's barrel) and tracks which packages are actually referenced by some -// loader (so only those packages get a barrel emitted). -type barrelRegistry struct { - filesByPkg map[string][]*protogen.File // package -> all files of that package - referenced []string // referenced packages, in first-seen order - seen map[string]bool // deduplication set for referenced -} - -// newBarrelRegistry groups every file known to the generator by proto package. -func newBarrelRegistry(gen *protogen.Plugin) *barrelRegistry { - r := &barrelRegistry{filesByPkg: map[string][]*protogen.File{}, seen: map[string]bool{}} - for _, f := range gen.Files { - pkg := string(f.Desc.Package()) - r.filesByPkg[pkg] = append(r.filesByPkg[pkg], f) - } - return r -} - -// markReferenced records that a package is imported by some loader. -func (r *barrelRegistry) markReferenced(pkg string) { - if r.seen[pkg] { - return - } - r.seen[pkg] = true - r.referenced = append(r.referenced, pkg) -} - -// generateBarrels emits one barrel module per referenced proto package into the -// barrel/ subdir of the loader output root. Each barrel re-exports every -// protobuf-es generated module of that package via `export *`, so a loader can -// import the whole package under a single namespace alias. This is -// collision-free because protobuf guarantees fully-qualified names are unique -// within a package, so the wildcard re-exports never clash. Because the barrel -// sits one level under the loader output root, an extra "../" is prepended to -// each module path (which moduleOf computes relative to that root). -func generateBarrels(gen *protogen.Plugin, reg *barrelRegistry) { - for _, pkg := range reg.referenced { - alias := helper.PackageAlias(pkg) - g := gen.NewGeneratedFile("barrel/"+alias+".pc.ts", "") - helper.GenerateFileHeader(gen, nil, g, version) - g.P(`// Barrel for proto package "`, pkg, `": re-exports every protobuf-es`) - g.P("// generated module of this package, so loaders import the whole package") - g.P("// under one namespace alias (import * as ", alias, `), matching the`) - g.P("// package-qualified naming used by loaders of other languages.") - g.P() - files := append([]*protogen.File(nil), reg.filesByPkg[pkg]...) - sort.Slice(files, func(i, j int) bool { - return files[i].Desc.Path() < files[j].Desc.Path() - }) - for _, f := range files { - g.P(`export * from "../`, moduleOf(f.Desc), `";`) - } - } -} From 7be5cac222f6d8df6751652723274b020b3b436f Mon Sep 17 00:00:00 2001 From: Kybxd <627940450@qq.com> Date: Wed, 17 Jun 2026 17:36:30 +0800 Subject: [PATCH 7/7] refactor(genhelper): unify MapKeySlice into a single generic type across loaders Define a single generic genhelper.MapKeySlice[F ParamFormatter] carrying all shared methods (AddMapKey/GenGetParams/GenGetArguments/GenCustom/ GenOtherArguments). Each loader only provides a zero-size ParamFormatter and aliases the slice, removing duplicated per-language definitions. The TS loader drops its MapKey wrapper/NeedToString field (derived from Type via IndexExpr) to reuse the same generic. Inline the former global helpers as MapKeySlice[F] methods. Generated output is byte-for-byte unchanged. --- .../helper/helper.go | 80 +++++-------------- cmd/protoc-gen-ts-tableau-loader/index.go | 7 +- cmd/protoc-gen-ts-tableau-loader/messager.go | 4 +- 3 files changed, 26 insertions(+), 65 deletions(-) diff --git a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go index a915d26..2af2e7b 100644 --- a/cmd/protoc-gen-ts-tableau-loader/helper/helper.go +++ b/cmd/protoc-gen-ts-tableau-loader/helper/helper.go @@ -1,7 +1,6 @@ package helper import ( - "fmt" "strings" "github.com/iancoleman/strcase" @@ -81,85 +80,50 @@ func ScalarTSType(kind protoreflect.Kind) (string, bool) { } } -// MapKey represents a single key component of a (possibly nested) map getter. -// It embeds the cross-language genhelper.MapKey (Type/Name/Fd/...) so its base -// structure stays aligned with the Go/C++/C# loaders, and adds the -// TypeScript-specific NeedToString flag. -type MapKey struct { - genhelper.MapKey - // NeedToString reports whether the parameter must be stringified to index - // the underlying protobuf-es map object (true for 64-bit / bool keys, whose - // JS object keys are always stored as strings). - NeedToString bool -} +// MapKey aliases the cross-language shared key descriptor; see genhelper.MapKey. +// The TypeScript loader no longer needs its own wrapper: the only TS-specific +// behaviour (stringifying 64-bit / bool keys) is derived from Type in IndexExpr. +type MapKey = genhelper.MapKey -// IndexExpr returns the expression used to index the underlying map object. -func (k MapKey) IndexExpr() string { - if k.NeedToString { - return k.Name + ".toString()" - } - return k.Name -} - -// MapKeySlice is an ordered collection of MapKey entries. -type MapKeySlice []MapKey +// tsParamFormatter formats a key as a TypeScript parameter declaration +// ("id: number"). +type tsParamFormatter struct{} -// AddMapKey appends a new map key, deduplicating the parameter Name across -// nested levels (e.g. "id" -> "id3") so generated getter signatures are valid. -func (s MapKeySlice) AddMapKey(newKey MapKey) MapKeySlice { - if newKey.Name == "" { - newKey.Name = fmt.Sprintf("key%d", len(s)+1) - } - for _, key := range s { - if key.Name == newKey.Name { - newKey.Name = fmt.Sprintf("%s%d", newKey.Name, len(s)+1) - break - } - } - return append(s, newKey) -} +func (tsParamFormatter) FormatParam(key MapKey) string { return key.Name + ": " + key.Type } -// GenGetParams generates the getter parameter list (e.g. "id: number, name: string"). -func (s MapKeySlice) GenGetParams() string { - var params []string - for _, key := range s { - params = append(params, key.Name+": "+key.Type) - } - return strings.Join(params, ", ") -} +// MapKeySlice is the shared cross-language key slice (see genhelper.MapKeySlice) +// specialized with TypeScript parameter formatting. All slice methods (AddMapKey +// / GenGetParams / GenGetArguments / ...) come from genhelper. +type MapKeySlice = genhelper.MapKeySlice[tsParamFormatter] -// GenGetArguments generates the getter argument list (e.g. "id, name"). -func (s MapKeySlice) GenGetArguments() string { - var args []string - for _, key := range s { - args = append(args, key.Name) +// IndexExpr returns the expression used to index the underlying protobuf-es map +// object. 64-bit ("bigint") and bool ("boolean") keys must be stringified +// because their JS object keys are always stored as strings. +func IndexExpr(k MapKey) string { + if k.Type == "bigint" || k.Type == "boolean" { + return k.Name + ".toString()" } - return strings.Join(args, ", ") + return k.Name } -// ParseMapKey returns the MapKey metadata (param type, stringify flag) for a -// map field's key descriptor (fd must be a map key descriptor). +// ParseMapKey returns the MapKey metadata (param type) for a map field's key +// descriptor (keyFd must be a map key descriptor). func ParseMapKey(keyFd protoreflect.FieldDescriptor, name string) MapKey { - key := MapKey{MapKey: genhelper.MapKey{Name: name}} + key := MapKey{Name: name} switch keyFd.Kind() { case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, protoreflect.Uint32Kind, protoreflect.Fixed32Kind: key.Type = "number" - key.NeedToString = false case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind: key.Type = "bigint" - key.NeedToString = true case protoreflect.BoolKind: key.Type = "boolean" - key.NeedToString = true case protoreflect.StringKind: key.Type = "string" - key.NeedToString = false default: // Map keys can only be integral, bool, or string. key.Type = "string" - key.NeedToString = false } return key } diff --git a/cmd/protoc-gen-ts-tableau-loader/index.go b/cmd/protoc-gen-ts-tableau-loader/index.go index 0a2b104..0e6ebf3 100644 --- a/cmd/protoc-gen-ts-tableau-loader/index.go +++ b/cmd/protoc-gen-ts-tableau-loader/index.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/tableauio/loader/cmd/protoc-gen-ts-tableau-loader/helper" - "github.com/tableauio/loader/internal/genhelper" "github.com/tableauio/loader/internal/index" "github.com/tableauio/loader/internal/loadutil" "github.com/tableauio/loader/internal/options" @@ -146,10 +145,8 @@ func (x *indexGen) params(idx *index.LevelIndex) helper.MapKeySlice { var keys helper.MapKeySlice for _, field := range idx.ColFields { keys = keys.AddMapKey(helper.MapKey{ - MapKey: genhelper.MapKey{ - Type: x.keyTSType(field.FD), - Name: helper.IndexFieldNameAsFuncParam(field.FD), - }, + Type: x.keyTSType(field.FD), + Name: helper.IndexFieldNameAsFuncParam(field.FD), }) } return keys diff --git a/cmd/protoc-gen-ts-tableau-loader/messager.go b/cmd/protoc-gen-ts-tableau-loader/messager.go index 9ba6fa5..062c82a 100644 --- a/cmd/protoc-gen-ts-tableau-loader/messager.go +++ b/cmd/protoc-gen-ts-tableau-loader/messager.go @@ -180,10 +180,10 @@ func genMapGetters(g *protogen.GeneratedFile, md protoreflect.MessageDescriptor, var access string if depth == 1 { - access = "this.#data." + localName + "[" + last.IndexExpr() + "]" + access = "this.#data." + localName + "[" + helper.IndexExpr(last) + "]" } else { prevArgs := keys[:len(keys)-1].GenGetArguments() - access = fmt.Sprintf("this.get%d(%s)?.%s[%s]", depth-1, prevArgs, localName, last.IndexExpr()) + access = fmt.Sprintf("this.get%d(%s)?.%s[%s]", depth-1, prevArgs, localName, helper.IndexExpr(last)) } g.P()