BEGIN;
-- 0) 必要拡張
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 0-1) updated_at を自動更新する共通トリガ関数
CREATE OR REPLACE FUNCTION public.tg_touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
------------------------------------------------------------
-- 1) コア: モデル
------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.portal_model (
id bigserial PRIMARY KEY,
model text NOT NULL UNIQUE, -- 例: 'demo.task'
model_table text NOT NULL, -- 例: 'demo_task'
label_i18n jsonb DEFAULT '{}'::jsonb,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_portal_model_model_table ON public.portal_model(model_table);
CREATE TRIGGER trg_touch_portal_model
BEFORE UPDATE ON public.portal_model
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
------------------------------------------------------------
-- 2) フィールド定義
-- ※ 既存環境の日本語列を持たせています。チェック制約は広めに設定。
------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.portal_fields (
id bigserial PRIMARY KEY,
model_id bigint REFERENCES public.portal_model(id) ON DELETE CASCADE,
model text NOT NULL,
model_table text NOT NULL,
field_name text NOT NULL, -- 技術名(英語)
ttype text NOT NULL, -- 例: char, integer, json...
label_i18n jsonb DEFAULT '{}'::jsonb, -- 表示名 i18n
code_status text,
notes text,
origin text DEFAULT 'portal',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- 既存環境に合わせた日本語カラム
"モデル技術名" text NOT NULL DEFAULT '',
"モデル物理名" text NOT NULL DEFAULT '',
"フィールド技術名" text NOT NULL DEFAULT '',
"データ型" text NOT NULL DEFAULT '文字列'
);
-- 日本語「データ型」のチェック制約(広めに許容)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ck_ttype_jp'
AND conrelid = 'public.portal_fields'::regclass
) THEN
ALTER TABLE public.portal_fields
ADD CONSTRAINT ck_ttype_jp CHECK (
"データ型" IN (
'文字列','テキスト','長文',
'整数','数値','小数','実数',
'真偽','ブール','論理値',
'日付','日時','タイムスタンプ',
'JSON','構造化',
'参照','外部キー',
'複数参照','1対多','多対多'
)
);
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_portal_fields_model ON public.portal_fields(model);
CREATE INDEX IF NOT EXISTS idx_portal_fields_model_id ON public.portal_fields(model_id);
CREATE TRIGGER trg_touch_portal_fields
BEFORE UPDATE ON public.portal_fields
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
------------------------------------------------------------
-- 3) ビュー定義
------------------------------------------------------------
BEGIN;
-- =========================================================
-- Safety: touch関数(updated_at自動更新)が無ければ定義
-- =========================================================
CREATE OR REPLACE FUNCTION public.tg_touch_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END
$$;
-- =========================================================
-- 既存の関連テーブルを削除(存在するもののみ)
-- ※ 旧構成:portal_view / portal_view_common / 種別設定 / 権限 等
-- =========================================================
DROP TABLE IF EXISTS public.portal_list_settings CASCADE;
DROP TABLE IF EXISTS public.portal_form_settings CASCADE;
DROP TABLE IF EXISTS public.portal_search_settings CASCADE;
DROP TABLE IF EXISTS public.portal_view_permissions CASCADE;
DROP TABLE IF EXISTS public.portal_view_creation CASCADE;
DROP TABLE IF EXISTS public.portal_view CASCADE;
DROP TABLE IF EXISTS public.portal_view_common CASCADE;
-- =========================================================
-- 新: portal_view_common
-- - portal_view_src(= ir_view_src CSV)の全カラムを同名で保持
-- - 共通UI設定・AI要約・作成挙動も保持
-- - action_xmlid を業務キー(1:1)として一意化
-- =========================================================
CREATE TABLE public.portal_view_common (
id bigserial PRIMARY KEY,
-- === ir_view_src 由来(同名保持) ===
action_xmlid text NOT NULL,
action_id text,
action_name text,
model_label text,
model_tech text,
model_table text,
view_types text,
primary_view_type text,
help_i18n_html jsonb DEFAULT '{}'::jsonb,
help_ja_html text,
help_ja_text text,
help_en_html text,
help_en_text text,
view_mode text,
context jsonb,
domain jsonb,
-- === 既存共通UI(従来の portal_view_common の継続カラム) ===
display_fields jsonb NOT NULL DEFAULT '[]'::jsonb,
sort_field text,
sort_dir text NOT NULL DEFAULT 'asc',
default_group_by text,
default_filters jsonb NOT NULL DEFAULT '{}'::jsonb,
-- === AI向けメタ(portal_view から移設) ===
ai_purpose text,
ai_purpose_i18n jsonb NOT NULL DEFAULT '{}'::jsonb,
-- === 作成挙動(portal_view_creation を統合) ===
creation_mode text NOT NULL DEFAULT 'open_new_page',
default_values text,
allow_duplicate boolean NOT NULL DEFAULT false,
enable_archive boolean NOT NULL DEFAULT true,
-- 監査
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_pvc_action_xmlid UNIQUE (action_xmlid),
CONSTRAINT ck_pvc_sort_dir CHECK (sort_dir IN ('asc','desc')),
CONSTRAINT ck_pvc_creation_mode CHECK (creation_mode IN ('open_new_page','inline_create','modal_create','quick_create'))
);
CREATE INDEX IF NOT EXISTS idx_pvc_model_tech ON public.portal_view_common(model_tech);
CREATE TRIGGER trg_touch_portal_view_common
BEFORE UPDATE ON public.portal_view_common
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
-- =========================================================
-- 新: portal_view
-- - 1レコード = 1ビュー種(全種対応)
-- - common_id で portal_view_common にぶら下がる
-- - 同一(common_id, view_type)は1件のみ
-- - 指定のビュー種ごとの項目をカラムで内包
-- =========================================================
CREATE TABLE public.portal_view (
id bigserial PRIMARY KEY,
common_id bigint NOT NULL REFERENCES public.portal_view_common(id) ON DELETE CASCADE,
view_type text NOT NULL,
-- 共通(全種)
view_name text,
model text,
priority_num integer,
enabled boolean NOT NULL DEFAULT true,
is_primary boolean NOT NULL DEFAULT false,
-- === Form ===
form_show_header boolean,
form_show_footer boolean,
-- === Kanban ===
kanban_default_group_by text,
kanban_quick_create boolean,
kanban_draggable_field text,
-- === List ===
list_inline_edit boolean,
list_export_button boolean,
list_page_size integer,
-- === Calendar ===
calendar_start_field date, -- ※ご指定により text → date
calendar_end_field date, -- ※ご指定により text → date
calendar_color_field text,
calendar_default_view text,
-- === Search ===
search_fields jsonb,
search_filters jsonb,
search_group_by_filters jsonb,
-- === Graph(暫定ドラフト) ===
graph_type text,
graph_measure_fields jsonb,
graph_row_group_by text,
graph_col_group_by text,
graph_stacked boolean,
-- === Pivot(暫定ドラフト) ===
pivot_measures jsonb,
pivot_rows jsonb,
pivot_cols jsonb,
pivot_show_totals boolean,
-- === Dashboard(暫定ドラフト) ===
dashboard_layout jsonb,
dashboard_widgets jsonb,
dashboard_refresh_secs integer,
-- === Tree(暫定ドラフト) ===
tree_parent_field text,
tree_expand_all boolean,
-- === Map(暫定ドラフト) ===
map_lat_field text,
map_lng_field text,
map_address_field text,
map_color_field text,
map_cluster boolean,
map_default_zoom integer,
-- 監査
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_pv_common_viewtype UNIQUE (common_id, view_type),
CONSTRAINT ck_pv_view_type CHECK (
view_type IN (
'form','kanban','list','calendar','search','graph','pivot','dashboard','tree','map'
)
),
CONSTRAINT ck_pv_calendar_default_view CHECK (
calendar_default_view IS NULL OR calendar_default_view IN ('month','week','day')
)
);
CREATE INDEX IF NOT EXISTS idx_pv_common_id ON public.portal_view(common_id);
CREATE INDEX IF NOT EXISTS idx_pv_view_type ON public.portal_view(view_type);
CREATE TRIGGER trg_touch_portal_view
BEFORE UPDATE ON public.portal_view
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
-- グループ内の is_primary を単一化するトリガ(任意:ONにする場合)
CREATE OR REPLACE FUNCTION public.tg_enforce_single_primary_per_common()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.is_primary THEN
UPDATE public.portal_view
SET is_primary = false, updated_at = now()
WHERE common_id = NEW.common_id
AND id <> NEW.id
AND is_primary = true;
END IF;
RETURN NEW;
END
$$;
DROP TRIGGER IF EXISTS trg_single_primary_per_common ON public.portal_view;
CREATE TRIGGER trg_single_primary_per_common
BEFORE INSERT OR UPDATE OF is_primary, common_id ON public.portal_view
FOR EACH ROW EXECUTE FUNCTION public.tg_enforce_single_primary_per_common();
COMMIT;
------------------------------------------------------------
-- 4) タブ(portal_tab_portal)
------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.portal_tab_portal (
id bigserial PRIMARY KEY,
view_id bigint NOT NULL REFERENCES public.portal_view(id) ON DELETE CASCADE,
tab_key text NOT NULL,
page_idx integer NOT NULL DEFAULT 10,
tab_label_ja text,
tab_label_en text,
child_model text,
child_link_field text,
origin text,
module text,
is_codegen_target boolean DEFAULT false,
notes text,
github_url text,
view_mode text,
tree_view_xmlid text,
form_view_xmlid text,
subview_policy_tree text,
subview_policy_form text,
use_domain boolean,
domain_raw text,
use_context boolean,
context_raw text,
inline_edit boolean,
allow_create_rows boolean,
allow_delete_rows boolean,
options_raw jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_portal_tab_portal_view_tab UNIQUE(view_id, tab_key)
);
CREATE INDEX IF NOT EXISTS idx_portal_tab_portal_view ON public.portal_tab_portal(view_id);
CREATE TRIGGER trg_touch_portal_tab_portal
BEFORE UPDATE ON public.portal_tab_portal
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
------------------------------------------------------------
-- 5) スマートボタン(portal_smart_button)
------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.portal_smart_button (
id bigserial PRIMARY KEY,
view_id bigint NOT NULL REFERENCES public.portal_view(id) ON DELETE CASCADE,
label_i18n jsonb DEFAULT '{}'::jsonb,
button_key text NOT NULL,
target_model text,
origin text,
action_type text,
action_ref text,
target text,
show_count boolean,
sequence integer,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
notes_i18n jsonb,
view_xmlid text,
cached_ui_view_id bigint,
dest_view_url text,
is_codegen_target boolean,
show_in_inspection boolean,
groups jsonb,
badge_count_expr text,
domain jsonb,
context jsonb,
CONSTRAINT uq_portal_smart_button_view_key UNIQUE(view_id, button_key)
);
CREATE INDEX IF NOT EXISTS idx_portal_smart_button_view ON public.portal_smart_button(view_id);
CREATE TRIGGER trg_touch_portal_smart_button
BEFORE UPDATE ON public.portal_smart_button
FOR EACH ROW EXECUTE FUNCTION public.tg_touch_updated_at();
------------------------------------------------------------
-- 6) 補助関数:日本語データ型ラベルの選定(ck_ttype_jpと整合)
------------------------------------------------------------
CREATE OR REPLACE FUNCTION public._pick_jp_datatype_label(p_ttype text)
RETURNS text AS $$
DECLARE
allowed text[];
cand text[];
v text;
BEGIN
SELECT array_agg(m[1]) INTO allowed
FROM pg_constraint c
CROSS JOIN LATERAL regexp_matches(pg_get_constraintdef(c.oid), '''([^'']+)''', 'g') AS m
WHERE c.conname = 'ck_ttype_jp'
AND c.conrelid = 'public.portal_fields'::regclass;
IF allowed IS NULL OR array_length(allowed,1) IS NULL THEN
RETURN '文字列';
END IF;
p_ttype := lower(coalesce(p_ttype,''));
cand := CASE p_ttype
WHEN 'char' THEN ARRAY['テキスト','文字列','文字','テキスト型','文字列型']
WHEN 'text' THEN ARRAY['テキスト','長文','文字列']
WHEN 'integer' THEN ARRAY['整数','数値','整数型']
WHEN 'float' THEN ARRAY['小数','実数','浮動小数点']
WHEN 'boolean' THEN ARRAY['真偽','ブール','論理値']
WHEN 'date' THEN ARRAY['日付']
WHEN 'datetime' THEN ARRAY['日時','タイムスタンプ']
WHEN 'json' THEN ARRAY['JSON','構造化']
WHEN 'many2one' THEN ARRAY['参照','外部キー']
WHEN 'one2many' THEN ARRAY['複数参照','1対多']
WHEN 'many2many' THEN ARRAY['多対多']
ELSE ARRAY['テキスト','文字列']
END;
FOREACH v IN ARRAY cand LOOP
IF v = ANY(allowed) THEN
RETURN v;
END IF;
END LOOP;
RETURN allowed[1];
END;
$$ LANGUAGE plpgsql STABLE;
------------------------------------------------------------
-- 7) スキャフォールド本体(tabs=portal_tab_portal / buttons=portal_smart_button)
------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.portal_scaffold_model(
p_model_id bigint,
p_view_types text[] DEFAULT ARRAY['list','form','search'],
p_make_tabs boolean DEFAULT true,
p_make_buttons boolean DEFAULT true
) RETURNS void AS $$
DECLARE
v_model text;
v_model_table text;
v_vtype text;
v_view_id bigint;
base_cols text := 'model_id, model, model_table, field_name, ttype, label_i18n, code_status, notes, updated_at';
label_expr text := 'jsonb_build_object(''ja_JP'',''(プレースホルダ)'',''en_US'',''(placeholder)'')';
extra_cols text := '';
extra_vals text := '';
columns_sql text;
values_sql text;
full_sql text;
rec RECORD;
def_sql text;
jp_dtype text;
BEGIN
-- 対象モデル
SELECT pm.model, COALESCE(pm.model_table, replace(pm.model,'.','_'))
INTO v_model, v_model_table
FROM public.portal_model pm
WHERE pm.id = p_model_id;
IF v_model IS NULL THEN
RAISE EXCEPTION 'portal_model id % not found', p_model_id;
END IF;
PERFORM pg_advisory_xact_lock(hashtext(v_model));
-- 日本語データ型ラベル(ck_ttype_jp に合わせる)
jp_dtype := public._pick_jp_datatype_label('char');
-- ===== portal_fields:NOT NULL/既定無し列を安全値で補完 =====
FOR rec IN
SELECT att.attname AS col,
format_type(att.atttypid, att.atttypmod) AS typ
FROM pg_attribute att
JOIN pg_class t ON t.oid = att.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname='public' AND t.relname='portal_fields'
AND att.attnum > 0 AND NOT att.attisdropped
AND att.attnotnull AND NOT att.atthasdef
AND att.attname NOT IN ('id','model_id','model','model_table','field_name','ttype',
'label_i18n','code_status','notes','updated_at')
LOOP
IF rec.col = 'モデル技術名' THEN def_sql := quote_literal(v_model) || '::text';
ELSIF rec.col = 'モデル物理名' THEN def_sql := quote_literal(v_model_table) || '::text';
ELSIF rec.col = 'フィールド技術名' THEN def_sql := quote_literal('__placeholder__') || '::text';
ELSIF rec.col = 'データ型' THEN def_sql := quote_literal(jp_dtype) || '::text';
ELSE
def_sql :=
CASE
WHEN rec.typ IN ('jsonb','json') THEN '''{}''::'||rec.typ
WHEN rec.typ LIKE '%[]' THEN '''{}''::'||rec.typ
WHEN rec.typ = 'boolean' THEN 'false'
WHEN rec.typ LIKE 'timestamp%' THEN 'now()'
WHEN rec.typ = 'date' THEN 'current_date'
WHEN rec.typ = 'uuid' THEN 'gen_random_uuid()'
WHEN rec.typ IN ('smallint','integer','bigint','numeric','real','double precision')
THEN '0'
ELSE 'CAST('''' AS '||rec.typ||')'
END;
END IF;
extra_cols := extra_cols || CASE WHEN extra_cols = '' THEN '' ELSE ', ' END || quote_ident(rec.col);
extra_vals := extra_vals || CASE WHEN extra_vals = '' THEN '' ELSE ', ' END || def_sql;
END LOOP;
columns_sql := base_cols || CASE WHEN extra_cols <> '' THEN ', '||extra_cols ELSE '' END;
values_sql := format(
'%s, %L, %L, %L, %L, %s, %L, %L, now()',
p_model_id, v_model, v_model_table,
'__placeholder__', 'char', label_expr,
'placeholder', 'scaffold: fields empty box'
);
full_sql := 'INSERT INTO public.portal_fields ('||columns_sql||') '||
'SELECT '||values_sql||
CASE WHEN extra_vals <> '' THEN ', '||extra_vals ELSE '' END||
' WHERE NOT EXISTS (SELECT 1 FROM public.portal_fields WHERE model='||quote_literal(v_model)||');';
EXECUTE full_sql;
-- ===== portal_view + 付随 =====
FOREACH v_vtype IN ARRAY p_view_types LOOP
INSERT INTO public.portal_view(model_id, model, view_type, view_name, enabled, priority_num)
SELECT p_model_id, v_model, v_vtype, v_model || ' ' || v_vtype, true, 16
WHERE NOT EXISTS (
SELECT 1 FROM public.portal_view WHERE model_id = p_model_id AND view_type = v_vtype
)
RETURNING id INTO v_view_id;
IF v_view_id IS NULL THEN
SELECT id INTO v_view_id
FROM public.portal_view
WHERE model_id = p_model_id AND view_type = v_vtype
ORDER BY id ASC LIMIT 1;
END IF;
IF v_view_id IS NOT NULL THEN
INSERT INTO public.portal_view_common(view_id, display_fields)
SELECT v_view_id, '[]'::jsonb
ON CONFLICT (view_id) DO NOTHING;
INSERT INTO public.portal_view_permissions(view_id, can_create, can_edit, can_delete, inline_edit, mass_edit, show_invisible)
SELECT v_view_id, true, true, false, false, false, false
ON CONFLICT (view_id) DO NOTHING;
IF v_vtype = 'list' THEN
INSERT INTO public.portal_list_settings(view_id, inline_edit)
SELECT v_view_id, false
ON CONFLICT (view_id) DO NOTHING;
END IF;
IF v_vtype = 'form' THEN
INSERT INTO public.portal_form_settings(view_id, show_header, show_footer)
SELECT v_view_id, true, true
ON CONFLICT (view_id) DO NOTHING;
END IF;
IF v_vtype = 'search' THEN
INSERT INTO public.portal_search_settings(view_id)
SELECT v_view_id
ON CONFLICT (view_id) DO NOTHING;
END IF;
-- TABS: portal_tab_portal
IF p_make_tabs AND v_vtype = 'form' THEN
INSERT INTO public.portal_tab_portal(view_id, tab_key, page_idx, tab_label_ja, tab_label_en, origin, notes)
SELECT v_view_id, x.tab_key, x.page_idx, x.tab_label_ja, x.tab_label_en, 'portal', 'scaffold: tabs empty'
FROM (VALUES
('main', 10, '基本', 'Main'),
('relations', 20, '関連', 'Relations'),
('notes', 30, 'メモ', 'Notes')
) AS x(tab_key, page_idx, tab_label_ja, tab_label_en)
WHERE NOT EXISTS (
SELECT 1 FROM public.portal_tab_portal t
WHERE t.view_id = v_view_id AND t.tab_key = x.tab_key
);
END IF;
-- SMART BUTTONS: portal_smart_button
IF p_make_buttons AND v_vtype = 'form' THEN
INSERT INTO public.portal_smart_button(
view_id, button_key, label_i18n, origin, action_type, target, show_count, sequence, notes
)
SELECT v_view_id, x.button_key,
jsonb_build_object('ja_JP', x.label_ja, 'en_US', x.label_en),
'portal', 'other', 'current', false, x.sequence, 'scaffold: button placeholder'
FROM (VALUES
('placeholder', 'ボタン', 'Button', 10)
) AS x(button_key, label_ja, label_en, sequence)
WHERE NOT EXISTS (
SELECT 1 FROM public.portal_smart_button b
WHERE b.view_id = v_view_id AND b.button_key = x.button_key
);
END IF;
END IF;
v_view_id := NULL;
END LOOP;
END;
$$ LANGUAGE plpgsql;
------------------------------------------------------------
-- 8) モデル挿入時の自動スキャフォールド・トリガ
------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.trg_portal_model_scaffold()
RETURNS trigger AS $$
BEGIN
PERFORM public.portal_scaffold_model(NEW.id, ARRAY['list','form','search'], true, true);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_after_ins_portal_model_scaffold ON public.portal_model;
CREATE TRIGGER trg_after_ins_portal_model_scaffold
AFTER INSERT ON public.portal_model
FOR EACH ROW EXECUTE FUNCTION public.trg_portal_model_scaffold();
COMMIT;
-- ■ 動作確認(任意)-----------------------------------------
-- 例: モデル1件を作ると、自動で fields / views / tabs / buttons が生成されます
-- INSERT INTO public.portal_model(model, model_table, label_i18n)
-- VALUES ('demo.task', 'demo_task', '{"ja_JP":"タスク","en_US":"Task"}');
コメントを残す