newmo では、地図データや地理情報を扱う場面が多くあります。
たとえば、タクシーやライドシェアでは、営業区域のような営業していいエリアといった地理的な定義があります。
また、乗り入れ禁止区域のようなタクシーが乗り入れてはいけないエリアといった定義も必要になります。
これらの地理に関する定義は GeoJSON のような地理情報を扱うデータ形式で管理されることが多いです。
しかし、GeoJSONなどの定義をテキストとして手書きするのは困難です。
そのため、地図上に区域を作図するエディタやその定義した区域が正しいかをチェックするような管理ツールが必要です。
管理ツールは、ウェブアプリケーションとして作った方が利用できる環境が広がります。
このような地理情報は一度に扱うデータが多かったり、空間的な計算処理が必要になるため、専用の仕組みを使うことが多いです。
このような技術を、地理情報システム(GIS:Geographic Information System)と呼びます。
ブラウザやウェブ標準には、GIS処理を行う仕組みは特にないため、すでに安定したGISの実装があるサーバ側で処理することも多いです。
しかし、データを変更するたびに、クライアントからサーバにリクエストしてサーバ側で GIS の処理を行うと、変更の反映がリアルタイムにできないため体験が良くありません。
そのため、地図情報の編集ツールといったプレビューの表示はクライアント側でも地理空間データ処理をして、操作内容を即時に反映できると体験を向上させることができます。
これらを行うには、ブラウザで動作する地理空間データ処理を行うライブラリが必要です。
ブラウザで動作する地理空間データ処理ライブラリとして DuckDB-wasm を使う
ブラウザで地理空間データ処理を行うライブラリとして何を使うかを検討して、最終的にDuckDB-wasmを使うことにしました。
DuckDB-wasmは、名前の通りDuckDBの WebAssembly ビルドです。
この、DuckDB-wasm はブラウザや Node.js など Wasm を実行できる環境で、DuckDB を動かすことができます。
DuckDB にはSpatial Extensionというものがあり、地理空間データ処理を扱えます。
データベースでは、Postgres 拡張のPostGISや SQLite 拡張のSpatiaLiteなどがあります。
DuckDB のSpatial Extensionもこれと同様の地理空間データ処理を扱う拡張で、面白い点として WebAssembly で動く点があげられます。
PostGIS と DuckDB の Spatial Extension をおおまかに比較してみると次のようになります。
また、JavaScriptで実装された地理空間データ処理ライブラリである Turf.js との比較も併せてみます。
基本的なCategory |
DuckDB WASM |
PostGIS |
Turf |
基本データ型 |
GEOMETRY, POINT_2D, LINESTRING_2D, POLYGON_2D |
geometry, geography |
Point, LineString, Polygonなど |
空間演算 |
ST_Area, ST_Distance, ST_Union |
ST_Area, ST_Distance, ST_Union, ST_Buffer |
area, distance, union, buffer |
空間関係 |
ST_Contains, ST_Within, ST_Touches |
ST_Contains, ST_Within, ST_Touches, ST_Crosses |
booleanContains, booleanWithin, booleanOverlap |
座標変換 |
ST_Transform |
ST_Transform, ST_SetSRID |
transform |
ジオメトリ生成 |
ST_MakePoint, ST_MakeLine |
ST_MakePoint, ST_MakeLine, ST_MakePolygon |
point, lineString, polygon |
集計関数 |
ST_Extent, ST_Union |
ST_Extent, ST_Union, ST_Collect |
collect, combine |
クラスタリング |
なし |
ST_ClusterDBSCAN, ST_ClusterKMeans |
clustersKmeans |
測地線計算 |
ST_Distance_Spheroid |
ST_Distance_Spheroid, ST_Length_Spheroid |
geodesicDistance |
GeoJSON 周りの比較 |
DuckDB WASM |
PostGIS |
Turf |
GeoJSON 変換 |
ST_AsGeoJSON |
ST_AsGeoJSON |
feature, featureCollection |
ジオメトリ → GeoJSON |
SELECT ST_AsGeoJSON(geom) |
SELECT ST_AsGeoJSON(geom) |
turf.feature(geometry) |
プロパティ付き Feature |
json extension |
ST_AsGeoJSON(t.*) |
turf.feature(geometry, properties) |
FeatureCollection 生成 |
json extension |
json_build_object('type','FeatureCollection','features',json_agg(ST_AsGeoJSON(t.*)::json)) |
turf.featureCollection(features) |
CRS 指定 |
ST_Transform + ST_AsGeoJSON |
ST_Transform + ST_AsGeoJSON1 |
なし(WGS84 固定) |
オプション |
基本的な GeoJSON 出力のみ |
maxdecimaldigits, bbox, CRS 指定など |
bbox, id 指定可能 |
基本作成 |
ST_MakePolygon, ST_Polygon |
ST_MakePolygon, ST_Polygon, ST_PolygonFromText |
polygon, multiPolygon |
Polygon 操作 |
DuckDB WASM |
PostGIS |
Turf |
ポリゴン演算 |
ST_Union, ST_Intersection, ST_Difference |
ST_Union, ST_Intersection, ST_Difference, ST_3DUnion |
union, intersect, difference |
空間分析 |
ST_Area, ST_Perimeter |
ST_Area, ST_Perimeter, ST_3DArea, ST_3DPerimeter |
area, perimeter |
空間関係 |
ST_Contains, ST_Within, ST_Overlaps7 |
ST_Contains, ST_Within, ST_Overlaps, ST_3DIntersects |
booleanContains, booleanWithin, booleanOverlap |
検証 |
ST_IsValid, ST_IsSimple |
ST_IsValid, ST_IsSimple, ST_IsValidReason |
isPolygon, isMultiPolygon |
変換 |
ST_Transform |
ST_Transform, ST_Force3D |
transformScale, transformRotate |
単純化関数 |
ST_Simplify |
ST_Simplify |
simplify,polygonSmooth |
サポートしているデータ形式 |
DuckDB WASM |
PostGIS |
Turf |
入力形式 |
GeoJSON, Shapefile, GeoPackage, KML, GML |
GeoJSON, Shapefile, GeoPackage, KML, GML, WKT, WKB |
GeoJSON のみ |
出力形式 |
GeoJSON, WKT, WKB |
GeoJSON, KML, SVG, WKT, WKB |
GeoJSON のみ |
GeoJSON 操作 |
ST_AsGeoJSON, ST_GeomFromGeoJSON |
ST_AsGeoJSON, ST_GeomFromGeoJSON |
feature, featureCollection10 |
KML 操作 |
ST_AsKML |
ST_AsKML, ST_GeomFromKML |
なし |
WKT 操作 |
ST_AsText, ST_GeomFromText |
ST_AsText, ST_GeomFromText |
なし |
データ読み込み |
ST_Read |
ST_Read, ST_AsBinary |
JSON.parse |
データ書き出し |
ST_AsGeoJSON, ST_AsText |
ST_AsGeoJSON, ST_AsKML, ST_AsSVG |
JSON.stringify |
おおまかに比較しても、DuckDB の Spatial Extension は PostGIS と同等の機能性を持っていることがわかります。
逆にTurf.jsは演算のみなので、データ形式の変換などについてはスコープ外となっていることもわかります。
ブラウザ上で動作する地理空間データ処理ライブラリを決めるために、このような機能比較、実装イメージ、ユースケース、サイズ、メリット/デメリットなどを書いた Design Doc を書いて議論しました。
その結果、DuckDB-wasm を使うことにしました。
📢 newmoでの Design Doc について
newmo のフロントエンドでは、大きなライブラリを導入する際には Design Doc を書いて議論してから決めることが多いです。
これについては、以前書いた One Version Rule を実践するためと、なぜそのライブラリを使っているのかという経緯が Design Doc(Architectural Decision Records としての役割)として残るためです。
この Design Doc とライブラリの管理などについては、yui_tang が 2024年11月23日(土曜) のJSConf JPで発表する予定です。
DuckDB-wasm + TypeScript で SQL を管理する
地理空間データ処理ライブラリとしてDuckDB-wasmを使うことにしました。
これは、クライアントサイドで SQL を書いて、クライアントサイドのWasm上で SQL を実行する必要があるということを意味しています。
今回は DuckDB のデータを永続化はせずに In-Memory DB として利用しているので、マイグレーションのような複雑な問題はありませんが、
それでも SQL を管理する方法は考える必要があります。
DuckDB-wasmはまだ新しいライブラリであるため、どのように扱うかのベストプラクティスが確立されていません。
そのため、今回 DuckDB-wasm を扱うにあたって、DuckDB で実行する SQL を TypeScript で管理する仕組みを作りました。
仕組みと言っても単純で、DuckDB で実行する SQL に型をつけて定義する Utility 関数を用意しただけです。
次のような SQL を実行できる関数を定義できる Utility 関数を提供しています。
defineQueryOne
: 一つの結果を返すクエリを実行する関数を定義する
defineQueryMany
: 複数の結果を返すクエリを実行する関数を定義する
defineQueryExec
: 結果を返さないクエリを実行する関数を定義する
transformQuery
: クエリの実行結果を変換して、変換した結果を返すようにするクエリのラッパー
また、どの関数も第一引数にDuckDBContext
を受け取り、DuckDBContext
は DuckDB と接続するための情報を持っています。
defineQuery.ts
: SQLを管理するUtility関数のコード(クリックで開く)
import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
export type DuckDBParserError = {
type: "DuckDBParserError";
message: string;
query: string;
cause: Error;
};
export type DuckDBConversionError = {
type: "DuckDBConversionError";
message: string;
query: string;
cause: Error;
};
export type DuckDBNoRowError = {
type: "DuckDBNoRowError";
message: string;
query: string;
cause: Error;
};
export type DuckDBUnknownError = {
type: "DuckDBUnknownError";
message: string;
query: string;
cause: Error;
};
export type DuckDBSQLError =
| DuckDBParserError
| DuckDBConversionError
| DuckDBNoRowError
| DuckDBUnknownError;
export type DuckDBContext = {
db: AsyncDuckDB;
conn: AsyncDuckDBConnection;
};
export const translateDuckDbError = ({
message,
query,
error,
}: {
message: string;
query: string;
error: unknown;
}): DuckDBSQLError => {
if (error instanceof Error) {
if (error.message.includes("Parser Error")) {
return {
type: "DuckDBParserError",
message,
query,
cause: error,
};
}
if (error.message.includes("Conversion Error")) {
return {
type: "DuckDBConversionError",
message,
query,
cause: error,
};
}
}
return {
type: "DuckDBUnknownError",
message,
query,
cause: error as Error,
};
};
export type QueryFunction<Input, Output> = keyof Input extends never
? QueryFunctionWithOptionalArgs<Input, Output>
: QueryFunctionWithArgsRequiredArgs<Input, Output>;
export type QueryFunctionWithArgsRequiredArgs<Input, Output> = (
context: DuckDBContext,
args: Input
) => Promise<
| {
ok: true;
data: Output;
}
| {
ok: false;
errors: DuckDBSQLError[];
}
>;
export type QueryFunctionWithOptionalArgs<Input, Output> = (
context: DuckDBContext,
args?: Input
) => Promise<
| {
ok: true;
data: Output;
}
| {
ok: false;
errors: DuckDBSQLError[];
}
>;
@param
@param
@example
export const defineQueryOne = <
Input,
Output
>({
name,
sql,
}: {
name: string;
sql: (args: Input) => string;
}): QueryFunction<Input, Output> => {
const fn = async (context: DuckDBContext, args?: Input) => {
const query = `-- name: ${name} :one
${sql(args ?? ({} as Input))}`;
try {
const q = await context.conn.prepare(query);
const resultTable = await q.query(args);
const firstData = resultTable.toArray()[0];
if (!firstData) {
return {
ok: false,
errors: [
{
type: "DuckDBNoRowError",
message: `No row found: ${name}`,
query,
},
],
};
}
return {
ok: true,
TODO
data: { ...firstData },
};
} catch (error: unknown) {
return {
ok: false,
errors: [
translateDuckDbError({
message: `Failed to query: ${name}`,
query,
error,
}),
],
};
}
};
Object.defineProperty(fn, "name", { value: name, configurable: true });
return fn as QueryFunction<Input, Output>;
};
@param
@param
@example
export const defineQueryMany = <
Input,
Output
>({
name,
sql,
}: {
name: string;
sql: (args: Input) => string;
}): QueryFunction<Input, Output[]> => {
const fn = async (context: DuckDBContext, args?: Input) => {
const query = `-- name: ${name} :many
${sql(args ?? ({} as Input))}`;
try {
const q = await context.conn.prepare(query);
const resultTable = await q.query(args);
return {
ok: true,
data: resultTable.toArray(),
};
} catch (error: unknown) {
return {
ok: false,
errors: [
translateDuckDbError({
message: `Failed to query: ${name}`,
query,
error,
}),
],
};
}
};
Object.defineProperty(fn, "name", { value: name, configurable: true });
return fn as QueryFunction<Input, Output[]>;
};
@param
@param
@example
export const defineQueryExec = <
Input
>({
name,
sql,
}: {
name: string;
sql: (args: Input) => string;
}): QueryFunction<Input, undefined> => {
const fn = async (context: DuckDBContext, args?: Input) => {
const query = `-- name: ${name} :exec
${sql(args ?? ({} as Input))}`;
try {
const q = await context.conn.prepare(query);
await q.query(args);
return {
ok: true,
data: undefined,
};
} catch (error: unknown) {
return {
ok: false,
errors: [
translateDuckDbError({
message: `Failed to query: ${name}`,
query,
error,
}),
],
};
}
};
Object.defineProperty(fn, "name", { value: name, configurable: true });
return fn as QueryFunction<Input, undefined>;
};
@example
export const transformQuery = <TransformOutput, Input, Output>(
query: QueryFunction<Input, Output>,
transformFn: (data: Output) => TransformOutput
): QueryFunction<Input, TransformOutput> => {
const fn = async (context: DuckDBContext, args?: Input) => {
const result = await query(context, args ?? ({} as Input));
if (!result.ok) {
return result;
}
return {
ok: true,
data: transformFn(result.data),
};
};
Object.defineProperty(fn, "name", {
value: `${query.name}WithTransformed`,
configurable: true,
});
return fn as QueryFunction<Input, TransformOutput>;
};
これらの Utility 関数を使って、次のように SQL を実行する関数を定義できます。
import { defineQueryOne, defineQueryMany, defineQueryExec } from "./defineQuery.ts";
export const installSpatialExtension = defineQueryExec({
name: "installSpatialExtension",
sql: () => `
INSTALL spatial;
LOAD spatial;
`,
});
export const createTable = defineQueryExec({
name: "createTable",
sql: () => `
CREATE TABLE table (
id STRING
name STRING
);
`,
});
export const selectId = defineQueryOne<
{ id: string },
{ id: string; name: string }
>({
name: "selectId",
sql: ({ id }) => `SELECT * FROM table WHERE id = ${id}`,
});
export const selectAll = defineQueryMany<{}, { id: string; name: string }>({
name: "selectAll",
sql: () => `SELECT * FROM table`,
});
export const insert = defineQueryExec<{ id: string; name: string }>({
name: "insert",
sql: ({ id, name }) => `INSERT INTO table VALUES (${id}, ${name})`,
});
これらのクエリは次のように実行できます。
DuckDB-wasm の使い方については、公式ドキュメントも参照してください。
import * as duckdb from "@duckdb/duckdb-wasm";
import duckdb_wasm from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm";
import duckdb_wasm_next from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm";
import { installSpatialExtension } from "./sql.ts";
const setupDuckDBForBrowser = async () => {
const MANUAL_BUNDLES: duckdb.DuckDBBundles = {
mvp: {
mainModule: duckdb_wasm,
mainWorker: new URL(
"@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js",
import.meta.url
).toString(),
},
eh: {
mainModule: duckdb_wasm_next,
mainWorker: new URL(
"@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js",
import.meta.url
).toString(),
},
};
const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
const worker = new Worker(bundle.mainWorker!);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
const conn = await db.connect();
const duckDBContext = {
conn,
db,
};
const installedResult = await installSpatialExtension(duckDBContext);
if (!installedResult.ok) {
throw new Error("Failed to install spatial extension", {
cause: installedResult.errors,
});
}
return duckDBContext;
};
const duckDBContext = await setupDuckDBForBrowser();
const insertResult = await insert(duckDBContext, { id: "1", name: "name" });
if (!insertResult.ok) {
console.error("Failed to insert", insertResult.errors);
return;
}
const selectResult = await selectId(duckDBContext, {
id: insertResult.data.id,
});
if (!selectResult.ok) {
console.error("Failed to select", selectResult.errors);
return;
}
console.log("select", selectResult.data);
const selectAllResult = await selectAll(duckDBContext);
if (!selectAllResult.ok) {
console.error("Failed to select all", selectAllResult.errors);
return;
}
console.log("select all", selectAllResult.data);
このdefineQuery*
関数などのUtilityは200-300行程度の小さなUtilityですが、クエリを定義する側はシンプルにSQLを書くだけで良くなります。
SQLのInputとOutputはTypeScriptで型定義することで、クエリを実行する側は型安全にクエリを実行できます。
また、クエリの実行結果は{ ok: true, data: Output }
または{ ok: false, errors: DuckDBSQLError[] }
というResult型のような値を返すようになっています。
これは、エラーも値として返したほうが型安全にエラーハンドリングを書きやすいためです。
DuckDB-wasm のテストをNode.jsで動かす
DuckDB-wasm はブラウザで動作するライブラリですが、WebAssemblyなのでNode.jsでも動かすことができます。
DuckDBで行う処理は特にブラウザに依存はしていないので、Node.jsで動くとテストが簡単に動かせるようになります。
Node.js向けの公式のドキュメントがまだ整備されていないので、参考程度になりますが、次のようにNode.jsでもDuckDB-wasmを使うことができます。
次のテストコードでは、"@duckdb/duckdb-wasm/blocking"
を使って、Node.jsでBlocking APIのDuckDBインスタンスを動かしています。
web-workerなどのNode.js向けのWeb Worker APIを使うと、ブラウザと同じ非同期APIのDuckDBを使うこともできます。
ただ、余計なライブラリが必要だったり、テスト目的ならBlocking APIでもあまり困らなかったので、"@duckdb/duckdb-wasm/blocking"
を使っています。
import { createDuckDB, NODE_RUNTIME } from "@duckdb/duckdb-wasm/blocking";
import { createRequire } from "module";
import { dirname, resolve } from "path";
import * as duckdb from "@duckdb/duckdb-wasm";
import {
defineQueryExec,
defineQueryMany,
defineQueryOne,
type DuckDBSQLError,
type DuckDBContext,
transformQuery,
} from "./defineQuery.ts";
import { describe, it, expect } from "vitest";
const require = createRequire(import.meta.url);
const DUCKDB_DIST = dirname(require.resolve("@duckdb/duckdb-wasm"));
@returns{Promise<void>}
export async function setupDuckDBForNodejs(): Promise<DuckDBContext> {
const DUCKDB_BUNDLES = {
mvp: {
mainModule: resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"),
mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-mvp.worker.cjs"),
},
eh: {
mainModule: resolve(DUCKDB_DIST, "./duckdb-eh.wasm"),
mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-eh.worker.cjs"),
},
};
TODO
const logger = new duckdb.ConsoleLogger();
const db = await createDuckDB(DUCKDB_BUNDLES, logger, NODE_RUNTIME);
await db.instantiate();
const conn = db.connect();
@ts-expect-error
const duckDBContext = {
db,
conn,
} as DuckDBContext;
const installResult = await installSpatialExtension(duckDBContext);
if (!installResult.ok) {
throw new Error("Failed to install spatial extension", {
cause: installResult.errors,
});
}
return duckDBContext;
}
@param
export function assertQueryResultOk(result: {
ok: boolean;
errors?: any[];
}): asserts result is { ok: true } {
if (!result.ok) {
const error = new Error(
"Assertion failed: query result is not ok. expected result.ok is true",
);
console.error(error, {
errors: result.errors,
});
throw error;
}
}
const createTestTable = defineQueryExec<{}>({
name: "createTestTable",
sql: () => `
CREATE TABLE test_table (
id UUID PRIMARY KEY DEFAULT uuid(),
name TEXT
)
`,
});
const insertTestItem = defineQueryOne<
{ name: string },
{
id: string;
}
>({
name: "insertTestItem",
sql: ({ name }) => `
INSERT INTO test_table (name) VALUES ('${name}')
RETURNING id
`,
});
const getTestItem = defineQueryOne<
{ id: string },
{ id: string; name: string }
>({
name: "getTestItem",
sql: ({ id }) => `
SELECT id, name FROM test_table WHERE id = '${id}'
`,
});
describe("DuckDB Utils", () => {
describe("defineQueryOne", () => {
it("should return one result", async () => {
const duckDBContext = await setupDuckDBForNodejs();
assertQueryResultOk(await createTestTable(duckDBContext));
const insertResult = await insertTestItem(duckDBContext, {
name: "test",
});
assertQueryResultOk(insertResult);
const insertedId = insertResult.data.id;
const result = await getTestItem(duckDBContext, {
id: insertedId,
});
assertQueryResultOk(result);
expect(result.data).toEqual({ id: insertedId, name: "test" });
});
});
});
これで定義したクエリのテストをNode.jsでも動かせるので、Unit Testなども簡単に書けるようになっています。
今後の展望
defineQuery*
関数で発行されるSQLは、-- name: ${name} :one
のような形式コメントを入れていることに気づいた人もいるかもしれません。
これはsqlcを意識して作った仕組みであるため、defineQuery*
関数もそれぞれsqlcのQuery annotationsに対応した形で作成しています。
sqlcは、SQLを書いてGoのコードやTypeScriptのコードを生成できるツールです。
現状のsqlcはDuckDBには対応していません。将来的には、SQLを書いてそのクエリを実行できるコードを生成するような仕組みに置き換えることも検討しています。
現状のUtilityはInputとOutputの型定義が完全に手動ですが、これらのツールが対応されるとDBのスキーマからTypeScriptで型定義を生成できたりしてより効率的に開発できるようになるかもしれません。
今回紹介した実装では、SQLのエスケープやprepared statementは特に対応を書いていません。
これは、実行するSQLの対象がブラウザ上の一時的な計算のためのデータで、漏れたり変更しても問題ないデータであるためです(あくまでデータはそのブラウザ内の値で、ページ内に閉じています)。
まだDuckDB Wasmのprepared statementの挙動がまだおかしい部分もあるため、できるだけシンプルな仕組みにしたかったのもあります。
おそらく、今後prepared statementの対応や@vercel/postgresのようなTagged Functionを使ったエスケープが必要になるかもしれません。
そのため、この記事のコードを利用する場合は、この点に留意してください。
また、defineQuery*
関数とは別にクエリの実行結果を変換できるtransformQuery
関数を提供しています。
クエリを実行できる関数の定義と変換処理を分けたのは、将来的にはクエリを実行できる関数は自動生成する可能性があると思ったためです。
クエリの定義と変換処理を分けておくことで、クエリの定義だけを自動生成するような仕組みを作りやすくなります。
そのため、defineQuery*
関数の中には、クエリの実行結果をあまり変換する処理は入れないようにしていて、シンプルな実行結果を返すだけの関数にしています。
まとめ
DuckDBのWebAssembly版であるDuckDB-wasmを使って、ブラウザ上で地理空間データ処理をするSQLの管理をする仕組みを作りました。
小さな仕組みですが、SQLはSQLとしてある程度独立したものとして定義できるようになり、型定義も明示的に書く必要があるのでTypeScriptからも扱いやすくなったと思います。
秩序なくアプリケーションのコードのSQLをベタがきしてしまうと、後から変更もできなくなってしまいます。
将来的には、SQLからコード生成をして、もっと安全で楽にDuckDB-wasmを使うような仕組みを作ることも検討しています。
宣伝
2024年11月16日(土曜)に開催されるTSKaigi Kansai 2024で、ブラウザで完結!DuckDB Wasmでタクシー地図情報を可視化というタイトルでスポンサーLTをするので、ぜひ聴きに来てください!
また、スポンサーブースでは、DuckDB-wasmの選定に使ったDesign Docや、今回のSQL管理の仕組みを議論したDesign Docなども展示する予定です
newmoでは地理情報システム(GIS:Geographic Information System)に興味のあるエンジニアを積極的に採用中です!