Compare commits

..

5 Commits

101 changed files with 3802 additions and 13387 deletions

View File

@ -1,21 +0,0 @@
---
name: "Standardize markdown document formatting"
description: "Standardize the formatting of all markdown documents to keep structure clear, content readable, and the overall quality and user experience consistent. This document explains requirements for heading levels, paragraph formatting, code block usage, list formatting, and image and link insertion so authors can follow a unified style that is easier to read and maintain."
applyTo: "*.{md,MD}"
---
# Primary Formatting Requirements
- Keywords or specialized terms in the document must be formatted with inline code, for example `RuntimeClass`, `containerd`, `GPU`, and `AI`.
- In Chinese text, do not add spaces around inline code.
- For technical articles, review the generated content before finalizing it to ensure the material is technically accurate and contains no incorrect technical descriptions.
- When the generated content is too large, split it into multiple tasks to avoid exceeding model output limits and causing the workflow to fail.
# Detailed Content Requirements
- When documenting parameters or configuration items for a component or project, prefer tables when practical, and keep tables short enough to avoid horizontal scrolling during normal reading.
- In Chinese paragraphs, use full-width punctuation rather than half-width punctuation, for example `` instead of `,` and `` instead of `;`.
- Use `mermaid` for architecture diagrams, flowcharts, and similar visuals. If you need line breaks inside `mermaid`, use `<br/>` instead of `\n`.
- If a code block is not a `mermaid` diagram and instead uses box-drawing characters such as `┌─`, `┐`, `┤`, or `│`, keep the content in English so the layout stays aligned.
- Do not use `---` as a separator between paragraphs.

View File

@ -1,152 +0,0 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@ -1,157 +0,0 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@ -1,173 +0,0 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@ -1,106 +0,0 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@ -1,280 +0,0 @@
---
name: gf-feedback
description: >-
Track, fix, verify, and test any bugs, improvements, or gaps reported against an OpenSpec change.
MUST use this skill whenever user reports problems, defects, issues, bugs, or gaps related to
existing implementations, even if they don't explicitly say "feedback" or mention OpenSpec.
compatibility: Requires openspec CLI, Go toolchain, and gf-review skill.
---
# Feedback: Structured Fix, Verification & Test Coverage Loop
When users discover bugs or improvement points after implementation, this skill captures those issues, organizes them into a traceable task list in `tasks.md`, systematically fixes and verifies each one, and ensures every behavior-changing fix is covered by focused unit tests.
**Core principles:**
1. **Spec is the source of truth** — Spec-level changes require spec update before task recording
2. **Write it down first, then fix it** — Every issue gets recorded before any code change
3. **Every fix deserves a test** — Behavior-changing code changes require unit test coverage in the affected package
---
## Workflow
### 1. Identify Target Change
**CRITICAL:**
1. Always append to existing active changes. Only create new change when none exist.
2. An **active change** is any change directory that still exists directly under `openspec/changes/` and has **not** been moved into `openspec/changes/archive/`. Do **not** treat `status: complete`, all tasks checked off, or similar completion signals as "inactive" until archive actually happens.
3. Regardless of whether the feedback content is related to the main functionality of the current active iteration, it MUST be appended to the current active iteration. This ensures all changes are tracked in a single change record for unified management and archiving.
```bash
openspec list --json
# Or: ls openspec/changes/ | grep -v archive
```
When the two signals disagree, prefer the filesystem rule:
- If a change directory still exists under `openspec/changes/` and is not inside `archive/`, it is active.
- `openspec list --json` may still report such a change as `status: complete`; that only means implementation tasks are done, **not** that the change is inactive.
- Only archived changes under `openspec/changes/archive/` are inactive.
| Active Changes | Action |
|----------------|--------|
| None | Create new change (see below) |
| One | Auto-select it, announce and proceed |
| Multiple | Ask user to select from list |
**When multiple active changes exist:**
```
Multiple active changes detected. Which change should this feedback be appended to?
1. config-management — System config CRUD management
2. user-auth — User authentication enhancement
Please select 1 or 2:
```
**When no active change exists:**
1. Derive kebab-case name from feedback (e.g., "fix-menu-circular-ref")
2. If name exists, append suffix ("-2")
3. Create: `openspec new change "<name>"`
4. Generate minimal `proposal.md` (one paragraph summarizing context)
5. Skip `design.md` for pure bug fixes unless architectural changes needed
Announce: "Applying feedback fixes to change: **<name>**"
---
### 2. Read Current Context
| File | Purpose |
|------|---------|
| `tasks.md` | Task structure, naming conventions, numbering |
| `design.md` | Architectural context |
| `proposal.md` | Feature scope and intent |
| `specs/` | Delta spec definitions |
```bash
# Locate existing unit tests in the impacted package before adding a new one
rg --files <pkg-dir> | rg '_test\\.go$'
```
---
### 3. Analyze and Organize Issues
For each reported issue:
**Classify by type:**
- **bug** — Incorrect behavior, code doesn't match spec
- **missing** — Feature incomplete, gaps in implementation
- **ux** — UX improvement, no spec change needed
- **test-gap** — Missing test coverage only
**Classify by spec impact:**
| Level | Definition | Action |
|-------|------------|--------|
| **implementation** | Spec is correct, code is wrong | Fix code only |
| **spec-level** | Requirement missing/incomplete/changed | Update spec first, then fix |
| **internal** | No user-observable change | Fix code, test optional |
**Group related issues** — Same root cause → single task with multiple verification points.
---
### 4. Update Delta Specs (for Spec-Level Issues Only)
For spec-level issues, update specs **before** recording tasks:
1. Identify affected capability: `specs/<capability>/spec.md`
2. Apply delta operation:
```markdown
<!-- ADDED: New requirement -->
### Requirement: Parent Selector Circular Prevention
The system SHALL disable the current menu and all its descendants in the parent selector
to prevent circular references.
#### Scenario: Edit menu with children
WHEN user edits a menu that has child menus
THEN the parent selector SHALL disable the current menu and all descendant menus
<!-- MODIFIED: Changed requirement (include full original block) -->
### Requirement: Import Error Handling
The system SHALL display error messages when import fails.
**MODIFIED:** Error messages SHALL include row number, field name, and validation failure reason.
<!-- REMOVED: Deprecated requirement -->
### Requirement: Legacy Import Format
The system SHALL support legacy CSV format.
**REMOVED:** This format is no longer supported.
**Migration:** Use the new CSV format with header row.
```
---
### 5. Write Task List to tasks.md
Append a **Feedback section** to `tasks.md`:
```markdown
## Feedback
- [ ] **FB-1**: Parent selector allows circular references in menu edit
- [ ] **FB-2**: Import error messages lack row and field details
- [ ] **FB-3**: No test coverage for reset password feature
```
**Numbering:** Sequential `FB-1`, `FB-2`, etc. Continue from last number if section exists.
**One line per task** — No sub-fields. Analysis happens during fix phase.
**Confirm with user** before writing to file.
**Test coverage planning (internal):**
- Behavior-changing code change → Unit test required
- Internal-only optimization → Unit test optional unless logic risk increased
- Prefer extending the nearest `*_z_unit*_test.go` or `*_test.go` in the same package
---
### 6. Execute Fixes (Loop)
For each task:
**a. Announce:** `## Fixing FB-X: <issue title>`
**b. Investigate** — Read source files, confirm root cause
**c. Implement** — Minimal, focused fix following existing patterns
**d. Write/update unit tests** — Prefer the affected package's existing `*_z_unit*_test.go` or `*_test.go` files and keep assertions focused on the changed logic
**e. Assess Impact Scope (MANDATORY)**
After implementing, identify regression risk:
| Change Type | Map To Tests |
|-------------|--------------|
| Package-level logic | Targeted test for changed function/method + package regression tests |
| Shared utility | Utility package unit tests + highest-value dependent package tests already covering reuse |
| DB/DAO logic | DAO/model package unit tests with focused fixtures, mocks, or test helpers |
| Public API validation | Handler/service package unit tests that assert the changed validation path |
| Refactor without behavior change | Existing package tests that prove behavior parity |
```bash
# Example: Find unit tests related to a changed symbol or package
rg -l "GenDao|gdao" . -g '*_test.go'
```
Announce:
```
### Impact Analysis for FB-X
- Modified: cmd/gf/internal/cmd/gendao/gendao.go
- Affected package: cmd/gf/internal/cmd/gendao
- Unit tests: cmd/gf/internal/cmd/gendao/gendao_test.go
- Regression command: go test ./cmd/gf/internal/cmd/gendao -run 'TestGenDao'
```
**f. Verify (MANDATORY before marking complete)**
1. Run new/updated unit tests for this task → **must pass**
2. Run ALL identified package-level regression tests → **must pass**
3. Only then: mark task `[x]` in tasks.md
If regression fails:
- Fix inline if related to current change
- Add as new FB task if separate issue
**g. Run review** — Invoke `gf-review` skill after completion
---
### 7. Comprehensive Verification
After all fixes:
1. Aggregate regression tests from all tasks
2. Run full set in single pass
3. Report:
```
### Comprehensive Verification Results
- Total tests: N
- Passed: N
- Failed: N (list with details)
- Regression tests: all passed ✓ / X failures
```
If failures → add new FB tasks, loop to Step 6.
---
### 8. Report Completion
```markdown
## Feedback Complete
**Change:** <name>
**Issues reported:** X
**Issues fixed:** Y/X
**Tests added:** Z unit tests / focused assertions
**Regression tests run:** R tests across N packages
**Verification:** all passed / N issues remaining
### Fixed This Session
- [x] FB-1: <title> ✓ (unit test: TestGenDao_FiltersInvalidTables | package: ./cmd/gf/internal/cmd/gendao ✓)
- [x] FB-2: <title> ✓ (unit test: existing package coverage extended | package: ./cmd/gf/internal/cmd ✓)
### Remaining (if any)
- [ ] FB-3: <title> — blocked by <reason>
```
---
## Edge Cases
| Situation | Handling |
|-----------|----------|
| Single issue | Still follow full workflow |
| Missing test cases only | Classify as test-gap, implement tests |
| Fix reveals more issues | Add as new FB tasks |
| "Bug" is actually feature request | Re-classify as spec-level, update specs first |
| Unit test not feasible (docs/spec only) | Note reason explicitly and skip only when no runtime code changes exist |
| Multiple feedback rounds | All tasks in single Feedback section, sequential numbering |
---
## Guardrails
- **Append to active change if exists** — Never create new change when active ones exist
- **Specs before tasks for spec-level issues** — Update delta specs first
- **Write tasks before fixing** — Never code without recording
- **Confirm task list with user** — User validates analysis
- **Minimal fixes** — No refactoring beyond issue scope
- **Behavior-changing fix needs unit test** — No exceptions unless the change is docs/spec only
- **No green check without green unit tests** — Mark `[x]` only after tests pass
- **Impact analysis mandatory** — Every fix requires package-level regression test identification
- **Regression failures block completion** — Must resolve before marking done
- **Update tasks.md in real time** — Mark complete immediately after verification
- **Match file language** — Use same language as existing content in target file

View File

@ -1,168 +0,0 @@
---
name: gf-review
description: >-
Code and specification review for OpenSpec workflow. Triggers automatically after /opsx:apply
task completion, after /gf-feedback task completion, and before /opsx:archive. Use when
user requests code review, spec compliance check, or when explicitly invoked via /gf-review.
compatibility: Requires OpenSpec CLI and GoFrame v2 skill.
---
# GF Review
Structured code and specification review for the OpenSpec development workflow.
**Spec Source**: `CLAUDE.md` is the single source of truth for all review criteria.
---
## When This Skill Activates
**Automatic triggers:**
- After completing each task in `/opsx:apply`
- After completing each task in `/gf-feedback`
- Before executing `/opsx:archive`
**Manual trigger:**
- User explicitly requests: "review this code", "check spec compliance", "/gf-review"
---
## Review Workflow
### 1. Identify Scope
Determine what needs to be reviewed:
1. **After task completion** — Review files modified/created by the completed task
2. **Before archive** — Review all changes in the current OpenSpec change
3. **Manual invocation** — Ask user to specify scope or use current change
**Mandatory scope collection rules:**
1. Start with repository status, not `git diff` alone:
```bash
git status --short
git ls-files --others --exclude-standard
```
2. Treat **all tracked and untracked changes** as review candidates, including:
- staged files
- unstaged files
- untracked files shown as `??`
- untracked directories shown as `?? path/`
3. When `git status --short` reports an untracked directory, expand it to concrete files before review:
```bash
find <path> -type f
# Or prefer:
rg --files <path>
```
4. If the task ran generators such as `make ctrl`, `make dao`, codegen scripts, or produced new test files, explicitly include the generated untracked files in review scope even if they do not appear in `git diff`.
5. `git diff` may be used only as a secondary narrowing aid after status collection. It is **never sufficient by itself** for review scope definition.
Run `openspec status --change "<name>" --json` to understand the current change state.
### 2. Load Specifications
Read `CLAUDE.md` to load all specifications. This is the single source of truth.
### 3. Backend Code Review
**Trigger**: Changes to files under `apps/lina-core` directory
1. Invoke `goframe-v2` skill for GoFrame framework conventions
2. Check against `CLAUDE.md` backend code specifications
### 4. RESTful API Review
**Trigger**: Any API endpoint changes
Check against `CLAUDE.md` API design specifications.
### 5. Project Specification Review
**Trigger**: Any implementation changes
Check against `CLAUDE.md` architecture design specifications and code development specifications.
### 6. SQL Review
**Trigger**: New or modified files under `apps/lina-core/manifest/sql/`、`apps/lina-core/manifest/sql/mock-data/`、`apps/lina-plugins/**/manifest/sql/` or SQL snippets embedded in related delivery docs
Check against `CLAUDE.md` SQL file management specifications, at minimum covering:
1. File naming, versioning, and single-iteration single-file rules
2. Seed DML vs mock data separation
3. **Idempotent execution safety** — SQL must be safe to run multiple times without duplicate-object errors or duplicate seed data; verify use of `IF [NOT] EXISTS`, `IF EXISTS`, `INSERT IGNORE`, or equivalent safe re-entry patterns
4. **Seed write style compliance** — delivered SQL must reject `INSERT ... ON DUPLICATE KEY UPDATE` and reject explicit writes to `AUTO_INCREMENT` `id` columns in seed/mock/install data
5. Whether schema/data changes still match the current change scope and deployment path
### 7. Unit Test Review
**Trigger**: New or modified Go implementation files, or new/modified Go unit test files matching `*_test.go`
Check at minimum:
1. Behavior-changing Go code includes focused unit coverage in the same package, preferably by extending existing `*_z_unit*_test.go` or `*_test.go`
2. Tests assert the changed logic directly instead of relying on broad workflow-level coverage when a package-level test is sufficient
3. Verification uses targeted `go test ./path/to/pkg -run TestName` during development and package-level `go test ./path/to/pkg` for regression
### 8. Generate Review Report
```markdown
## GF Review Report
**Change:** <change-name>
**Scope:** <task-specific / full change>
**Files Reviewed:** <count>
**Scope Source:** `git status --short` + `git ls-files --others --exclude-standard` + task/change context
### Backend Code Review
✓ All checks passed / ⚠ N issues found
### RESTful API Review
✓ All endpoints compliant / ⚠ N violations found
### Project Spec Review
✓ Compliant with CLAUDE.md / ⚠ N violations found
### SQL Review
✓ No SQL changes / ✓ SQL changes compliant / ⚠ N SQL issues found
### Unit Test Review
✓ Unit tests are focused and sufficient / ⚠ N issues found
### Summary
- **Critical:** N (must fix before archive)
- **Warnings:** N (recommended to fix)
### Recommended Actions
1. [Specific action with CLAUDE.md reference]
```
---
## Issue Severity
| Level | Behavior |
|-------|----------|
| **Critical** | Block archive, must fix |
| **Warning** | Show but allow proceed |
---
## Integration Points
| Workflow Step | Behavior |
|---------------|----------|
| `/opsx:apply` task done | Review, offer to fix issues before next task |
| `/gf-feedback` task done | Review, fix before marking complete |
| `/opsx:archive` | Review all changes, block on critical issues |
---
## Guardrails
- **CLAUDE.md is the single source of truth** — All spec references point to it
- Only check categories relevant to changed files
- Scope identification MUST include untracked files and expanded untracked directories; never rely on `git diff` alone
- Behavior-changing Go code without focused unit tests is a review finding unless the author documents why tests are not applicable
- Don't block on warnings — only critical issues block archive
- Include file paths and line numbers in issue reports
- Offer to fix issues automatically when straightforward

View File

@ -1,148 +0,0 @@
---
name: git-commit-push
description: Review the current git working tree, generate a commit message from the actual diff using the repository's commit or PR-title convention, commit all current changes on the active branch, and push that branch to `origin`. Use this whenever the user asks to "commit", "push", "commit and push", "generate a commit message", "commit the current changes", or wants the current branch changes sent upstream without hand-writing the git commands.
---
# Git Commit Push
Inspect the current repository changes, derive a concise commit subject that matches the repository convention, commit every current modification on the active branch, and push that branch to `origin`.
This skill is for execution, not just advice. When it triggers, actually run the git workflow unless the repository state makes that unsafe or impossible.
## When To Use
- The user asks you to commit the current changes, with or without asking for push
- The user wants you to write the commit message from the diff instead of inventing one up front
- The user mentions the repo's PR or commit naming convention and wants you to follow it
- The user says things like "commit the current branch", "help me commit", "commit and push", "generate a commit message and push", or "send these changes to origin"
## Core Behavior
1. Confirm you are inside a Git repository and detect the active branch with `git branch --show-current`.
2. Inspect the working tree before committing:
- `git status --short --branch`
- `git diff --stat`
- `git diff --cached --stat`
- `git diff -- . ':(exclude)package-lock.json'` or narrower path filters only when needed for readability
3. If the repository contains `.github/PULL_REQUEST_TEMPLATE.MD`, read it and treat its PR-title rules as the default commit-subject convention.
4. Generate a commit subject from the actual changed files and diff content, not from the user prompt alone.
5. Stage every current modification on the branch with `git add -A`.
6. Commit once with the generated message.
7. Push the current branch to `origin` with `git push origin <current-branch>`.
## Commit Message Rules
The commit message is formatted as follows: `<type>[optional scope]: <description>` For example, `fix(os/gtime): fix time zone issue`
+ `<type>` is mandatory and can be one of `fix`, `feat`, `build`, `ci`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
+ `fix`: Used when a bug has been fixed.
+ `feat`: Used when a new feature has been added.
+ `build`: Used for modifications to the project build system, such as changes to dependencies, external interfaces, or upgrading Node version.
+ `ci`: Used for modifications to continuous integration processes, such as changes to Travis, Jenkins workflow configurations.
+ `docs`: Used for modifications to documentation, such as changes to README files, API documentation, etc.
+ `style`: Used for changes to code style, such as adjustments to indentation, spaces, blank lines, etc.
+ `refactor`: Used for code refactoring, such as changes to code structure, variable names, function names, without altering functionality.
+ `perf`: Used for performance optimization, such as improving code performance, reducing memory usage, etc.
+ `test`: Used for modifications to test cases, such as adding, deleting, or modifying test cases for code.
+ `chore`: Used for modifications to non-business-related code, such as changes to build processes or tool configurations.
+ After `<type>`, specify the affected package name or scope in parentheses, for example, `(os/gtime)`.
+ The part after the colon uses the verb tense + phrase that completes the blank in
+ Lowercase verb after the colon
+ No trailing period
+ Keep the title as short as possible. ideally under 76 characters or shorter
+ If there is a corresponding issue, add either `fixes #1234` (the latter if this is not a complete fix) to this comment
### Examples
#### Commit message with description and breaking change footer
```
feat: allow provided config object to extend other configs
BREAKING CHANGE: `extends` key in config file is now used for extending other config files
```
#### Commit message with ! to draw attention to breaking change
```
feat!: send an email to the customer when a product is shipped
```
#### Commit message with scope and ! to draw attention to breaking change
```
feat(api)!: send an email to the customer when a product is shipped
```
#### Commit message with both ! and BREAKING CHANGE footer
```
feat!: drop support for Node 6
BREAKING CHANGE: use JavaScript features not available in Node 6.
```
#### Commit message with no body
```
docs: correct spelling of CHANGELOG
```
#### Commit message with scope
```
feat(lang): add Polish language
```
#### Commit message with multi-paragraph body and multiple footers
```
fix: prevent racing of requests
Introduce a request id and a reference to latest request. Dismiss
incoming responses other than from latest request.
Remove timeouts which were used to mitigate the racing issue but are
obsolete now.
Reviewed-by: Z
Refs: #123
```
## Execution Rules
- Commit all current tracked and untracked changes in the working tree, because this skill is for "commit the current state" requests
- If there are no changes, say so clearly and stop before commit or push
- If `git branch --show-current` is empty, explain that `HEAD` is detached and stop unless the user explicitly asks you to commit from detached `HEAD`
- Never use `--force`, `--force-with-lease`, or history-rewriting commands unless the user explicitly asks
- If push fails because the remote branch moved, report the exact failure and stop instead of auto-rebasing or auto-merging
- Do not silently drop files from the commit unless the user asked to exclude them
## Suggested Command Flow
```bash
git status --short --branch
git diff --stat
git diff --cached --stat
test -f .github/PULL_REQUEST_TEMPLATE.MD && sed -n '1,220p' .github/PULL_REQUEST_TEMPLATE.MD
branch_name=$(git branch --show-current)
git add -A
git commit -m "<generated-subject>"
git push origin "$branch_name"
```
Inspect `git diff --cached` again after staging if the pre-stage diff was noisy or if untracked files materially change the scope.
## Output Contract
When you use this skill:
- Tell the user which branch you committed
- Provide the final commit subject you used
- Mention that you staged all current changes
- Report the push target as `origin/<branch>`
- If commit or push did not happen, explain exactly why
## Example
User request:
```text
Generate a commit message that follows this repository's convention, then commit and push the current branch
```
Expected behavior:
- Inspect the repo status and diff
- Generate a conventional subject from the real changes
- Run one commit for the whole current working tree
- Push the active branch to `origin`

View File

@ -1,142 +0,0 @@
---
name: git-worktree
description: Create and actively use an isolated git worktree for the user's task, then continue the task inside that new directory. Use this whenever the user asks for a separate worktree, isolated checkout, clean branch directory, safer parallel changes, or a fresh workspace to avoid unrelated local edits.
---
# Git Worktree
Create a dedicated `git worktree` for the current task, then keep working inside that new directory instead of the original checkout.
This skill is about execution, not just advice. When it triggers, actually create the worktree unless the repository state makes that impossible.
Do not introduce helper scripts for this skill. Use direct `git` and shell commands inline.
## When To Use
- The user explicitly asks for a new `git worktree`, independent branch directory, or isolated workspace
- The current checkout contains unrelated local changes and isolation is the safest way forward
- The user wants parallel work on multiple tasks without stashing or disturbing the original worktree
- The user says things like "create a separate branch folder", "open a fresh worktree", "use a clean checkout", or "work in an isolated workspace"
## Core Rule
After creating the worktree, treat the new path as the active working directory for the rest of the task.
In any agent environment, "enter the directory" means:
- Run subsequent commands against the new worktree path
- Apply all edits under that worktree path
- Do not keep using the original checkout by accident
- Confirm the handoff by running at least one follow-up command in the new worktree
Never claim you "switched" unless your subsequent actions actually target the new `worktree_path`.
If your environment supports a per-command working directory, use it for every later command. If it does not, prefix later commands with an explicit `cd <worktree_path> && ...`.
## Name Derivation
- Derive a short ASCII kebab-case task slug from the user's real task, such as `login-timeout-fix` or `user-export`
- Do not use generic names like `git-worktree`, `new-worktree`, or `task` unless the request is too vague
- If the request is mostly non-ASCII or no good slug is obvious, fall back to `task-$(date +%Y%m%d-%H%M%S)`
- Default branch prefix is `worktree/`
- Default worktree directory is a sibling of the repository root, named `<repo-name>-<slug>`
## Default Workflow
1. Inspect the repository context from the current checkout:
- `git rev-parse --show-toplevel`
- `git branch --show-current`
- `git status --short`
- `git worktree list --porcelain`
2. Decide a task slug yourself using the rules above
3. Build branch and path names inline, then create the worktree with direct shell commands like:
```bash
repo_root=$(git rev-parse --show-toplevel)
repo_name=$(basename "$repo_root")
parent_dir=$(dirname "$repo_root")
source_branch=$(git -C "$repo_root" branch --show-current)
if [ -n "$source_branch" ]; then
source_ref="$source_branch"
else
source_ref="HEAD@$(git -C "$repo_root" rev-parse --short HEAD)"
fi
slug="<task-slug>"
base_branch="worktree/$slug"
branch_name="$base_branch"
base_path="$parent_dir/$repo_name-$slug"
worktree_path="$base_path"
index=2
while git -C "$repo_root" show-ref --verify --quiet "refs/heads/$branch_name" || [ -e "$worktree_path" ]; do
branch_name="${base_branch}-$index"
worktree_path="${base_path}-$index"
index=$((index + 1))
done
git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" HEAD
```
4. Immediately verify the handoff inside the new worktree, for example:
```bash
pwd
git status --short --branch
```
These verification commands must run against `worktree_path`.
5. Announce the new active path briefly, then continue the main task there
6. For the remainder of the task, use `worktree_path` as the working directory for every relevant command or edit operation
## Behavior Rules
- Default base ref is `HEAD` from the current checkout so uncommitted local changes are not dragged into the new worktree
- If a branch name or path already exists, auto-increment it instead of failing
- If you are already inside a non-default worktree and the user still wants another isolated workspace, create a new one from the current `HEAD`
- If the directory is not a Git repository, explain that clearly and do not pretend a worktree was created
- If worktree creation succeeds, continue the user's actual task instead of stopping at setup
- If worktree creation fails because of filesystem permissions, request the minimal approval needed and retry
## Uncommitted Change Policy
The safe default is isolation from uncommitted changes.
- If the source checkout is dirty, still create the new worktree from `HEAD` unless the user explicitly asks to carry local edits over
- Do not silently stash, reset, or move the user's existing changes
- If the user wants local edits copied into the new worktree, use an explicit flow such as a temporary commit, patch, or cherry-pick, and say what you are doing
## Output Contract
When you use this skill:
- Tell the user which branch and directory were created
- Make it clear that subsequent work is now happening inside that path
- Mention the source ref and whether the original checkout was dirty when that context matters
- Do not stop after setup if the user asked for additional work; continue the task in the new worktree
## Example
User request:
```text
Create a separate worktree for this task and then start implementing it.
```
Expected behavior:
- Inspect current repo status
- Create a new `worktree/...` branch and sibling directory with direct `git worktree` commands
- Switch all following commands to that directory
- Continue the requested implementation there
## Cleanup
Only remove a worktree when the user asks or when cleanup is clearly part of the task.
Before cleanup:
- Check status in the worktree you created
- Make sure you are removing the correct path
- Never remove the user's original checkout

View File

@ -1,288 +0,0 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@ -1,110 +0,0 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@ -1 +0,0 @@
../.agents/prompts

View File

@ -1 +0,0 @@
../.agents/skills

View File

@ -1,5 +0,0 @@
approval_policy = "never"
sandbox_mode = "danger-full-access"
[sandbox_workspace_write]
network_access = true

3
.gitignore vendored
View File

@ -25,4 +25,5 @@ node_modules
output
.example/
.golangci.bck.yml
*.exe
*.exe
.aiprompt.zh.md

View File

@ -1 +0,0 @@
CLAUDE.md

211
CLAUDE.md
View File

@ -1,211 +0,0 @@
# Repository Overview
This is the [GoFrame](https://goframe.org) framework (`github.com/gogf/gf/v2`) — a modular Go application framework. The repository is a **multi-module monorepo**: the root module hosts the core framework, while `cmd/gf` and every directory under `contrib/` are independent Go modules with their own `go.mod`.
- Root module: `github.com/gogf/gf/v2` (Go 1.23+)
- Tooling module: `cmd/gf` (CLI: `gf` command, separate `go.work`)
- Plugin modules under `contrib/`: `config/*`, `drivers/*` (mysql, pgsql, mssql, oracle, sqlite, clickhouse, dm, gaussdb, mariadb, oceanbase, tidb, sqlitecgo), `metric/*`, `nosql/*`, `registry/*`, `rpc/grpcx`, `sdk/*`, `trace/*`. Each is published as its own module so users only pull what they need.
The root framework intentionally has **minimal external dependencies** (see `go.mod`). Anything requiring a heavy third-party dep (a DB driver, a registry client, an RPC stack) lives in `contrib/`.
## Top-level package map
| Path | Purpose |
| --- | --- |
| `frame/g`, `frame/gins` | Convenience facade (`g.Server()`, `g.DB()`, `g.Cfg()`) and the singleton/instance container |
| `net/` | `ghttp` (HTTP server/router), `gclient` (HTTP client), `gtcp`, `gudp`, `gsel` (load balancing), `gsvc` (service discovery), `gtrace`, `goai` (OpenAPI) |
| `os/` | OS abstractions: `gcfg` (config), `gcmd` (CLI), `gcron`, `glog`, `gfile`, `gres` (resource embedding), `gview` (templating), `gcache`, `gsession`, `gctx`, `gtimer`, `gproc`, `gmetric`, `gfsnotify`, `gstructs` |
| `database/` | `gdb` (ORM/query builder, driver-agnostic core), `gredis` (Redis client core) |
| `container/` | Concurrent-safe data structures: `garray`, `gmap`, `gset`, `gtree`, `glist`, `gqueue`, `gring`, `gpool`, `gtype`, `gvar` |
| `encoding/` | Codecs for JSON/XML/YAML/TOML/INI/Properties, base64, charsets, compression, hashes, HTML, URL |
| `text/` | `gstr`, `gregex` |
| `util/` | `gconv` (universal conversion — heavily used), `gvalid` (validation), `gutil`, `grand`, `guid`, `gtag`, `gmeta`, `gpage`, `gmode` |
| `crypto/` | `gaes`, `gdes`, `grsa`, `gmd5`, `gsha*`, `gcrc32` |
| `errors/` | `gerror` (stack-aware errors), `gcode` (error code registry) |
| `internal/` | Framework-internal helpers (do not import from outside the root module) |
| `test/gtest` | The framework's own testing helpers — used throughout the test suite |
Concrete database drivers and Redis adapters live under `contrib/drivers/` and `contrib/nosql/`; the `database/gdb` and `database/gredis` packages define the abstract layer.
# Common Commands
All commands run from the repository root unless noted.
## Build / lint / tidy
```bash
# Tidy every go.mod in the repo (root, cmd/gf, contrib/*) — strips // indirect and toolchain lines
make tidy
# Run the project's golangci-lint config (.golangci.yml)
make lint
# Equivalent: golangci-lint run -c .golangci.yml
```
`make tidy` invokes `.make_tidy.sh`, which `cd`s into every directory containing a `go.mod` (skipping `testdata/` and `examples/`) and runs `go mod tidy`. After editing imports in any module, run this from the repo root.
## Tests
Each Go module must be tested from inside its own directory because they are separate modules. The CI script (`.github/workflows/scripts/ci-main.sh`) iterates every `go.mod`:
```bash
# Build + race-enabled tests for the root module
go build ./...
go test ./... -count=1 -race
# Coverage (matches CI 'coverage' mode)
go test ./... -count=1 -race -coverprofile=coverage.out -covermode=atomic \
-coverpkg=./...,github.com/gogf/gf/...
# Run a single package's tests
go test -count=1 -race ./os/gcfg/...
# Run a single test by name
go test -count=1 -race -run TestCfg_Get ./os/gcfg/
# Test a contrib module — must cd in first (separate go.mod)
cd contrib/drivers/mysql && go test ./... -count=1 -race
```
Many tests (database drivers, registry clients, redis cluster, apollo/nacos config) require backing services. CI starts them via docker-compose files under `.github/workflows/{redis,apollo,nacos,consul}/`. Locally, use:
```bash
make docker # start the default local stack
make docker cmd=start svc=mysql # start a specific service
make docker cmd=stop svc=mysql
```
## CLI tool
```bash
cd cmd/gf && go run . <subcommand> # iterate on the gf CLI itself
```
# Architecture Notes Worth Knowing Up Front
- **The `frame/g` package is a facade, not a library.** It re-exports types and provides singleton accessors (`g.DB()`, `g.Server()`, `g.Cfg()`, `g.Log()`) backed by `frame/gins`. Examples in docs use `g.*` heavily; framework-internal code generally imports the underlying packages directly.
- **`util/gconv` is foundational.** Most public APIs accept `any` and use `gconv` for type coercion. When changing argument handling, search for `gconv.` usage to understand the conversion contract.
- **`gdb` is driver-agnostic.** The core in `database/gdb` exposes interfaces; concrete drivers (`contrib/drivers/mysql`, etc.) register themselves via `init()` when imported. The same model pattern applies to `gredis`, `gcfg` (apollo/nacos/polaris adapters), and `gsvc` (etcd/consul/nacos/polaris/zookeeper registries).
- **`internal/` is private.** Sub-packages (`intlog`, `instance`, `reflection`, `utils`, `json`, `command`) are not part of the public API surface — do not import them from outside the root module, and avoid leaking their types in exported signatures.
- **Tests use `gtest`, not stdlib `testing` directly.** `test/gtest` provides `gtest.C(t, func(t *gtest.T){...})` blocks, fluent assertions (`t.Assert`, `t.AssertNE`, `t.AssertNil`), and is the project-wide convention. Match this style when adding tests.
- **CI matrix.** Tests run on Go 1.23, 1.24, 1.25 across 386 and amd64. `contrib/*` only runs on the latest Go version (`LATEST_GO_VERSION` in `.github/workflows/ci-main.yml`). Code that requires the latest stdlib should live in `contrib/` or be guarded.
- **Lint config matters.** `.golangci.yml` enforces a 380-char line limit, function length up to 340 lines, custom import grouping (`gci` with `prefix(github.com/gogf/gf/v2)` ahead of `cmd`/`contrib`/`example`), `gofmt -s` with rewrites (`interface{}``any`, `ioutil.*``io`/`os`, `reflect.Ptr``reflect.Pointer`). Run `make lint` before pushing.
- **OpenSpec changes live under `openspec/changes/`** and drive every non-trivial change through the workflow defined below. The active iteration directory must be checked before starting work — see the Development Workflow Rules section.
# Karpathy Guidelines
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
# Documentation Writing Rules
Technical documentation such as `README.md` must follow `.agents/instructions/markdown-format.instructions.md`.
- All directory-level primary documentation files in the repository must use the English `README.md` and provide a matching Chinese mirror in `README.zh_CN.md`.
- When adding a new directory documentation file, create both `README.md` and `README.zh_CN.md` in the same change. Maintaining only one language version is not allowed.
# Development Workflow Rules
This project follows `SDD` and uses the `OpenSpec` tool to drive implementation. Change records are stored under `openspec/changes/`. Each change includes `proposal.md`, `design.md`, `specs/`, and `tasks.md`.
**Execution workflow**:
1. Use the `/opsx:explore` slash command at `.agents/prompts/opsx/explore.md` to conduct an exploratory discussion based on the requirement description, analyze the problem, design the solution, and assess risks.
2. Once the exploratory discussion finishes and the solution is clear, use the `/opsx:propose` slash command at `.agents/prompts/opsx/propose.md` to turn it into a formal `OpenSpec` change proposal. The command format is `/opsx:propose feature-name`, where `feature-name` is a descriptive name for the current change in `kebab-case`, such as `user-auth` or `data-export`. A new change directory will then be generated automatically under `openspec/changes`, containing incremental spec documents (`spec/`), the technical implementation plan (`design.md`), the proposal and rationale (`proposal.md`), and the implementation task list (`tasks.md`).
3. Then run the `/opsx:apply` slash command at `.agents/prompts/opsx/apply.md` to execute the items in `tasks.md` one by one, completing code changes, tests, and documentation updates. After the work is done, the `/gf-review` skill must be invoked for code and spec review.
4. When users report issues or improvement requests, the `/gf-feedback` skill must be used to fix and verify them, and the related `OpenSpec` documents must be updated. After the work is done, the `/gf-review` skill must be invoked for review.
5. After the user confirms that the current iteration is complete and has no remaining issues, run the `/opsx:archive` slash command at `.agents/prompts/opsx/archive.md` to archive the change. Before archiving, the `/gf-review` skill must be used for a full change review to ensure code quality and compliance with the spec.
**Key rules**:
- **An `OpenSpec` change is considered active until it is archived**: any change directory that still exists directly under `openspec/changes/` and has **not been moved to** `openspec/changes/archive/` is an active change. **Even if the change has completed all tasks and `openspec list --json` shows `status: complete`, it must still be treated as active until the archive step has been executed.**
- When a user reports a bug, defect, or improvement request in either Chinese or English, and there is an active `OpenSpec` change in the project, the `gf-feedback` skill must be used. **Unless the user explicitly asks to create a new change, the feedback must always be appended to the current active iteration, even if it is unrelated to the main feature of that iteration**, so that everything can be managed and archived together.
- The `/gf-review` review skill is triggered automatically after `/opsx:apply` completes, after `/opsx:feedback` completes, and before `/opsx:archive`.
- During development tasks executed with tools such as `Claude Code` or `Codex CLI`, if the work can be parallelized effectively with `SubAgent` and doing so would clearly improve efficiency, that option must be evaluated first and adopted whenever appropriate. Only skip `SubAgent` when the task is strongly dependent on serial context, the split cost is too high, or it introduces obvious collaboration risk.
- When creating new iteration documents, the content of `proposal.md`, `design.md`, `tasks.md`, and the incremental specs must be written in English.
# Code Development Rules
- All source code must include comments, such as package comments, file comments, method comments for both public and private methods, constant comments, variable comments, and comments for key logic.
- **All submitted code changes must include unit tests**: every submitted code change must add or update focused unit tests that directly cover the affected logic and expected behavior of the changed code path, and the coverage for newly added code must stay at or above 80%; 90% or above is the preferred target when feasible.
- **Do not hardcode string literals with enum semantics in backend implementation code**: values that represent statuses, types, stages, actions, execution modes, sort directions, filter operators, or any other enum-like semantics must be managed through Go named types and constants. Writing raw string literals directly in business branching, comparisons, assignments, or persistence logic is forbidden.
- **Do not ignore any `error` return value**: every call that may return an `error` must be handled explicitly. Do not use patterns such as `_ = someFunc()`, `_, _ = someFunc()`, or direct calls that discard returned errors. In business flows, errors should be returned explicitly or converted before returning; in initialization, startup, or other critical non-recoverable paths, they should `panic`; in tests and cleanup paths, they must still be asserted, logged, or otherwise handled explicitly rather than silently ignored.
- **Do not use stand-alone assignments like `_ = var` to mask unused parameters or local variables**: this placeholder pattern has no business meaning and creates misleading signals about whether the variable was supposed to participate in the logic. Prefer deleting unused variables. If a parameter must be kept to satisfy an interface signature or callback contract, use the blank identifier directly in the function signature, such as `func(ctx context.Context, _ gdb.TX) error`, or omit an unused receiver name instead of adding one-line statements like `_ = tx`, `_ = req`, or `_ = ctx` in the function body.
- **File header comment rules**:
- Every `Go` source file must include a file-purpose comment at the top of the file. Component-level comments should appear in the component's main file, meaning the file with the same name as the component, such as `plugin.go`, `config.go`, or `file.go`.
- In a main file, the component comment must be placed immediately before the `package xxx` declaration with no blank line in between. For example:
```go
// Package plugin implements plugin manifest discovery, lifecycle orchestration,
// governance metadata synchronization, and host integration for LinaPro plugins.
package plugin
```
- Other implementation files must keep only file comments that describe the responsibility of the current file, such as `plugin_xxx.go` or `config_xxx.go`. There must be one blank line between the file comment and `package xxx`, and non-main files must not duplicate component-level descriptions.
- **Variable Declarations**: When defining multiple variables, use a `var` block to group them for better alignment and readability:
```go
// Good - aligned and clean
var (
authSvc *auth.Service
bizCtxSvc *bizctx.Service
k8sSvc *svcK8s.Service
notebookSvc *notebook.Service
middlewareSvc *middleware.Service
)
// Avoid - scattered declarations
authSvc := auth.New()
bizCtxSvc := bizctx.New()
k8sSvc := svcK8s.New()
```
Apply this pattern when you have 3 or more related variable declarations in the same scope.

View File

@ -1,5 +1,27 @@
SHELL := /bin/bash
# commit changes with AI-generated commit message
.PHONY: up
up:
@if git diff --quiet HEAD && git diff --cached --quiet && [ -z "$$(git ls-files --others --exclude-standard)" ]; then \
echo "No changes to commit"; \
exit 0; \
fi
@git add -A
@echo "Analyzing changes and generating commit message via AI..."
@set -e; \
MSG=$$(git diff --cached --stat && echo "---" && git diff --cached | head -2000 | \
claude -p "Analyze the git diff above and generate a concise commit message (single line, max 72 chars, lowercase, no quotes). Output only the commit message itself, nothing else." \
--model haiku) || { echo "Error: Claude command failed"; exit 1; }; \
COMMIT_MSG=$$(echo "$$MSG" | tail -1); \
if [ -z "$$COMMIT_MSG" ]; then \
echo "Error: Failed to generate commit message"; \
exit 1; \
fi; \
echo "Commit: $$COMMIT_MSG"; \
git commit -m "$$COMMIT_MSG" && \
git push origin $$(git branch --show-current)
# execute "go mod tidy" on all folders that have go.mod file
.PHONY: tidy
tidy:

View File

@ -89,7 +89,6 @@ func GetCommand(ctx context.Context) (*Command, error) {
cmd.Install,
cmd.Version,
cmd.Doc,
cmd.CfgEditor,
)
if err != nil {
return nil, err

View File

@ -1,112 +0,0 @@
# GoFrame Config Editor
A web-based visual configuration editor for GoFrame projects. It reads your `config.yaml` and provides an interactive UI to view, edit, and save configuration fields with type-aware inputs, validation, and i18n support.
## Quick Start
```bash
gf config # Start on port 8888, auto-detect config file
gf config -p 9000 # Use a custom port
gf config -f manifest/config/config.yaml # Specify config file path
```
The browser opens automatically at `http://127.0.0.1:<port>`.
## Features
### Supported Modules
| Module | Config Node | Description |
|--------|-------------|-------------|
| Server | `server` | HTTP server settings (address, timeouts, TLS, sessions, logging, PProf) |
| Database | `database` | Database connections (host, port, credentials, pool, timeouts) |
| Redis | `redis` | Redis connections (address, auth, pool, sentinel, cluster) |
| Logger | `logger` | Logging configuration (level, rotation, output) |
| Viewer | `viewer` | Template engine settings (paths, delimiters, auto-encode) |
### UI Features
- **Type-aware inputs**: bool fields get toggle switches, duration fields get text input with placeholder hints, map/slice fields get JSON editors
- **Default value display**: each field shows its default value from struct tags
- **Validation**: fields with `v:"required"` tags are validated on blur
- **Modified tracking**: changed fields are marked with a blue indicator bar
- **Group collapse**: fields are organized by logical groups (Basic, Connection, Pool, etc.)
- **Search**: search fields by name, key, description, or type (supports Chinese)
- **i18n**: switch between English and Chinese field descriptions
- **Export format**: save as YAML, TOML, or JSON
- **Keyboard shortcut**: `Ctrl/Cmd + S` to save
### Config File Detection
When no `-f` flag is provided, the editor searches these paths in order:
1. `config.yaml` / `config.yml` / `config.toml` / `config.json`
2. `config/config.yaml` (and variants)
3. `manifest/config/config.yaml` (and variants)
4. `app.yaml` / `app.yml`
### Nested Config Support
GoFrame stores database and redis configs under group names:
```yaml
database:
default:
host: 127.0.0.1
port: 3306
redis:
default:
address: 127.0.0.1:6379
```
The editor correctly reads and writes these nested structures.
## Architecture
```
cmd/gf/internal/cmd/
├── cmd_config.go # CLI command + REST API handlers
├── resources/
│ ├── templates/index.html # Vue 3 + Tailwind CSS SPA
│ ├── static/vue.global.prod.js # Vue 3 runtime
│ ├── static/tailwind.min.css # Tailwind CSS
│ └── i18n/{en,zh-CN}.yaml # Field descriptions
os/gcfg/
├── gcfg_schema.go # Schema registry (FieldSchema, ModuleSchema, SchemaRegistry)
└── gcfg_z_unit_schema_test.go # Unit tests
```
### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/schemas` | Returns all registered module schemas (fields, types, defaults, rules) |
| GET | `/api/config` | Returns current config values from file |
| POST | `/api/config/validate` | Validates config values against schema rules |
| POST | `/api/config/save` | Saves config to file (preserves YAML comments) |
| GET | `/api/i18n/:lang` | Returns i18n translations for the given language |
### Struct Tags
Configuration field metadata is extracted from struct tags:
| Tag | Purpose | Example |
|-----|---------|---------|
| `json` | YAML/JSON key | `json:"address"` |
| `d` | Default value | `d:":0"` |
| `v` | Validation rule (gvalid) | `v:"required"` |
| `dc` | Description + i18n key | `dc:"Server address\|i18n:config.server.address"` |
## Development
### Building
```bash
go build ./cmd/gf/...
```
### Testing
```bash
go test -count=1 -v ./os/gcfg/... -run TestSchema
```

View File

@ -1,575 +0,0 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package cmd
import (
"context"
"embed"
"fmt"
"io/fs"
"net/http"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/encoding/gyaml"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gview"
"github.com/gogf/gf/v2/util/gvalid"
"github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog"
)
//go:embed resources/i18n/*.yaml
var i18nFS embed.FS
//go:embed resources/templates/index.html
var configEditorHTML string
//go:embed resources/static/*
var staticFS embed.FS
var (
// CfgEditor is the management object for `gf config` command.
CfgEditor = cCfgEditor{}
)
type cCfgEditor struct {
g.Meta `name:"config" brief:"start the configuration visual editor"`
}
type cCfgEditorInput struct {
g.Meta `name:"config" config:"gfcli.config"`
Port int `short:"p" name:"port" brief:"web server port" d:"8888"`
File string `short:"f" name:"file" brief:"configuration file path"`
}
type cCfgEditorOutput struct{}
func init() {
registerAllSchemas()
}
// registerAllSchemas registers configuration schemas for the five core modules.
func registerAllSchemas() {
// Server
gcfg.RegisterSchema("server", "server", ghttp.ServerConfig{}, map[string]string{
"Name": "Basic", "Address": "Basic", "HTTPSAddr": "Basic",
"HTTPSCertPath": "Basic", "HTTPSKeyPath": "Basic",
"ReadTimeout": "Basic", "WriteTimeout": "Basic", "IdleTimeout": "Basic",
"MaxHeaderBytes": "Basic", "KeepAlive": "Basic", "ServerAgent": "Basic",
"IndexFolder": "Static", "ServerRoot": "Static", "FileServerEnabled": "Static",
"CookieMaxAge": "Cookie", "CookiePath": "Cookie", "CookieDomain": "Cookie",
"CookieSameSite": "Cookie", "CookieSecure": "Cookie", "CookieHttpOnly": "Cookie",
"SessionIdName": "Session", "SessionMaxAge": "Session", "SessionPath": "Session",
"SessionCookieMaxAge": "Session", "SessionCookieOutput": "Session",
"LogPath": "Logging", "LogLevel": "Logging", "LogStdout": "Logging",
"ErrorStack": "Logging", "ErrorLogEnabled": "Logging", "ErrorLogPattern": "Logging",
"AccessLogEnabled": "Logging", "AccessLogPattern": "Logging",
"PProfEnabled": "PProf", "PProfPattern": "PProf",
"OpenApiPath": "API", "SwaggerPath": "API", "SwaggerUITemplate": "API",
"Graceful": "Graceful", "GracefulTimeout": "Graceful", "GracefulShutdownTimeout": "Graceful",
"ClientMaxBodySize": "Other", "FormParsingMemory": "Other",
"NameToUriType": "Other", "RouteOverWrite": "Other", "DumpRouterMap": "Other",
"Endpoints": "Other", "Rewrites": "Other", "IndexFiles": "Other", "SearchPaths": "Other",
"StaticPaths": "Other", "Listeners": "Other",
})
// Database
gcfg.RegisterSchema("database", "database", gdb.ConfigNode{}, map[string]string{
"Host": "Connection", "Port": "Connection", "User": "Connection",
"Pass": "Connection", "Name": "Connection", "Type": "Connection",
"Link": "Connection", "Extra": "Connection", "Protocol": "Connection",
"Charset": "Connection", "Timezone": "Connection", "Namespace": "Connection",
"MaxIdleConnCount": "Pool", "MaxOpenConnCount": "Pool",
"MaxConnLifeTime": "Pool", "MaxIdleConnTime": "Pool",
"Role": "Role", "Debug": "Role", "Prefix": "Role", "DryRun": "Role", "Weight": "Role",
"QueryTimeout": "Timeout", "ExecTimeout": "Timeout",
"TranTimeout": "Timeout", "PrepareTimeout": "Timeout",
"CreatedAt": "AutoTimestamp", "UpdatedAt": "AutoTimestamp",
"DeletedAt": "AutoTimestamp", "TimeMaintainDisabled": "AutoTimestamp",
})
// Redis
gcfg.RegisterSchema("redis", "redis", gredis.Config{}, map[string]string{
"Address": "Connection", "Db": "Connection", "User": "Connection",
"Pass": "Connection", "Protocol": "Connection",
"MinIdle": "Pool", "MaxIdle": "Pool", "MaxActive": "Pool",
"MaxConnLifetime": "Pool", "IdleTimeout": "Pool", "WaitTimeout": "Pool",
"DialTimeout": "Timeout", "ReadTimeout": "Timeout", "WriteTimeout": "Timeout",
"MasterName": "Sentinel", "SentinelUser": "Sentinel", "SentinelPass": "Sentinel",
"TLS": "Security", "TLSSkipVerify": "Security",
"SlaveOnly": "Security", "Cluster": "Security",
})
// Logger
gcfg.RegisterSchema("logger", "logger", glog.Config{}, map[string]string{
"Flags": "Basic", "TimeFormat": "Basic", "Path": "Basic",
"File": "Basic", "Level": "Basic", "Prefix": "Basic",
"HeaderPrint": "Output", "StdoutPrint": "Output", "LevelPrint": "Output",
"StdoutColorDisabled": "Output", "WriterColorEnable": "Output",
"StSkip": "Stack", "StStatus": "Stack", "StFilter": "Stack",
"RotateSize": "Rotate", "RotateExpire": "Rotate",
"RotateBackupLimit": "Rotate", "RotateBackupExpire": "Rotate",
"RotateBackupCompress": "Rotate", "RotateCheckInterval": "Rotate",
})
// Viewer
gcfg.RegisterSchema("viewer", "viewer", gview.Config{}, map[string]string{
"Paths": "Basic", "Data": "Basic", "DefaultFile": "Basic",
"Delimiters": "Basic", "AutoEncode": "Basic",
})
}
// Index starts the config editor web server.
func (c cCfgEditor) Index(ctx context.Context, in cCfgEditorInput) (out *cCfgEditorOutput, err error) {
mlog.Printf("[ConfigEditor] Starting with port=%d, file=%q", in.Port, in.File)
// Verify embedded i18n files are accessible.
for _, lang := range []string{"en", "zh-CN"} {
path := "resources/i18n/" + lang + ".yaml"
if data, e := i18nFS.ReadFile(path); e != nil {
mlog.Printf("[ConfigEditor] WARNING: embedded i18n file %q not found: %v", path, e)
} else {
mlog.Printf("[ConfigEditor] Embedded i18n file %q loaded, size=%d bytes", path, len(data))
}
}
s := g.Server("gf-config-editor")
s.SetPort(in.Port)
s.SetDumpRouterMap(false)
// API endpoints.
s.Group("/api", func(group *ghttp.RouterGroup) {
group.GET("/schemas", apiGetSchemas)
group.GET("/config", apiGetConfig(in.File))
group.POST("/config/validate", apiValidateConfig)
group.POST("/config/save", apiSaveConfig)
group.GET("/i18n/:lang", apiGetI18n)
})
// Serve embedded static files.
s.BindHandler("/static/*", func(r *ghttp.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/static/")
data, err := fs.ReadFile(staticFS, "resources/static/"+filePath)
if err != nil {
r.Response.WriteStatus(http.StatusNotFound)
return
}
if strings.HasSuffix(filePath, ".js") {
r.Response.Header().Set("Content-Type", "application/javascript; charset=utf-8")
} else if strings.HasSuffix(filePath, ".css") {
r.Response.Header().Set("Content-Type", "text/css; charset=utf-8")
}
r.Response.Header().Set("Cache-Control", "public, max-age=86400")
r.Response.Write(data)
})
// Serve the embedded UI.
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.WriteHeader(http.StatusOK)
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
r.Response.Write(configEditorHTML)
})
addr := fmt.Sprintf("http://127.0.0.1:%d", in.Port)
mlog.Printf("[ConfigEditor] GoFrame Config Editor starting at %s", addr)
go func() {
time.Sleep(500 * time.Millisecond)
if err := openBrowser(addr); err != nil {
mlog.Printf("[ConfigEditor] WARNING: failed to open browser: %v", err)
}
}()
s.Run()
return
}
// apiGetSchemas returns all registered module schemas.
func apiGetSchemas(r *ghttp.Request) {
schemas := gcfg.GetAllSchemas()
r.Response.WriteJsonExit(g.Map{
"code": 0,
"data": schemas,
})
}
// apiGetConfig returns the current configuration values.
func apiGetConfig(file string) func(r *ghttp.Request) {
return func(r *ghttp.Request) {
configFile := file
if configFile == "" {
searchPaths := []string{
"config.yaml", "config.yml", "config.toml", "config.json",
"config/config.yaml", "config/config.yml",
"config/config.toml", "config/config.json",
"manifest/config/config.yaml", "manifest/config/config.yml",
"manifest/config/config.toml", "manifest/config/config.json",
"app.yaml", "app.yml",
}
for _, name := range searchPaths {
if gfile.Exists(name) {
configFile = name
break
}
}
}
data := g.Map{}
filePath := ""
fileType := ""
if configFile != "" && gfile.Exists(configFile) {
filePath = gfile.RealPath(configFile)
fileType = gfile.ExtName(configFile)
content := gfile.GetBytes(configFile)
j, err := gjson.LoadContent(content)
if err != nil {
r.Response.WriteJsonExit(g.Map{
"code": 1,
"message": fmt.Sprintf("Failed to parse config file %q: %v", filePath, err),
})
return
}
data = j.Map()
}
r.Response.WriteJsonExit(g.Map{
"code": 0,
"data": g.Map{
"config": data,
"filePath": filePath,
"fileType": fileType,
},
})
}
}
// apiValidateConfig validates configuration values using gvalid.
func apiValidateConfig(r *ghttp.Request) {
var reqData struct {
Module string `json:"module"`
Values map[string]any `json:"values"`
}
if err := r.Parse(&reqData); err != nil {
r.Response.WriteJsonExit(g.Map{"code": 1, "message": err.Error()})
return
}
schema, ok := gcfg.GetSchema(reqData.Module)
if !ok {
r.Response.WriteJsonExit(g.Map{"code": 1, "message": fmt.Sprintf("module %q not found", reqData.Module)})
return
}
// Build validation rules from schema fields.
var rules []string
for _, field := range schema.Fields {
if field.Rule == "" {
continue
}
rule := field.JsonKey + "|" + field.Rule
rules = append(rules, rule)
}
if len(rules) > 0 {
if err := gvalid.New().Data(reqData.Values).Rules(rules).Run(r.Context()); err != nil {
// Parse validation errors into field-level messages.
validationErrors := make(map[string]string)
if vErr, ok := err.(gvalid.Error); ok {
for _, item := range vErr.Items() {
for field, ruleErrMap := range item {
for _, ruleErr := range ruleErrMap {
validationErrors[field] = ruleErr.Error()
break
}
}
}
} else {
validationErrors["_general"] = err.Error()
}
r.Response.WriteJsonExit(g.Map{
"code": 1,
"message": "Validation failed",
"errors": validationErrors,
})
return
}
}
r.Response.WriteJsonExit(g.Map{
"code": 0,
"message": "Valid",
})
}
// apiSaveConfig saves configuration to file.
func apiSaveConfig(r *ghttp.Request) {
var reqData struct {
Config map[string]any `json:"config"`
FilePath string `json:"filePath"`
FileType string `json:"fileType"`
}
if err := r.Parse(&reqData); err != nil {
r.Response.WriteJsonExit(g.Map{"code": 1, "message": err.Error()})
return
}
if reqData.FilePath == "" {
reqData.FilePath = "config.yaml"
reqData.FileType = "yaml"
}
var err error
switch reqData.FileType {
case "yaml", "yml":
err = saveYAMLPreservingComments(reqData.FilePath, reqData.Config)
default:
j := gjson.New(reqData.Config)
var content string
switch reqData.FileType {
case "toml":
content, err = j.ToTomlString()
case "json":
content, err = j.ToJsonIndentString()
case "ini":
content, err = j.ToIniString()
default:
content, err = j.ToYamlString()
}
if err == nil {
err = gfile.PutContents(reqData.FilePath, content)
}
}
if err != nil {
r.Response.WriteJsonExit(g.Map{"code": 1, "message": err.Error()})
return
}
r.Response.WriteJsonExit(g.Map{
"code": 0,
"message": "Configuration saved successfully",
"data": g.Map{
"filePath": gfile.RealPath(reqData.FilePath),
},
})
}
// saveYAMLPreservingComments writes the config map to a YAML file while preserving
// any existing comments in the file.
func saveYAMLPreservingComments(filePath string, newConfig map[string]any) error {
var (
docNode yaml.Node
indent = 2
)
if gfile.Exists(filePath) {
content := gfile.GetBytes(filePath)
indent = detectYAMLIndent(content)
if err := yaml.Unmarshal(content, &docNode); err != nil {
docNode = yaml.Node{}
}
}
if docNode.Kind == 0 {
docNode = yaml.Node{Kind: yaml.DocumentNode}
docNode.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
} else if docNode.Kind == yaml.DocumentNode {
if len(docNode.Content) == 0 {
docNode.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
} else if docNode.Content[0].Kind != yaml.MappingNode {
docNode.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
}
}
applyMapToYAMLNode(docNode.Content[0], newConfig)
var buf strings.Builder
enc := yaml.NewEncoder(&buf)
enc.SetIndent(indent)
if err := enc.Encode(&docNode); err != nil {
return err
}
_ = enc.Close()
return gfile.PutContents(filePath, buf.String())
}
// detectYAMLIndent returns the number of spaces used for indentation in the YAML content.
func detectYAMLIndent(content []byte) int {
for _, line := range strings.Split(string(content), "\n") {
trimmed := strings.TrimLeft(line, " ")
if len(trimmed) == 0 || strings.HasPrefix(trimmed, "#") {
continue
}
spaces := len(line) - len(trimmed)
if spaces > 0 {
return spaces
}
}
return 2
}
// applyMapToYAMLNode recursively merges updates into an existing yaml.MappingNode,
// preserving comments and formatting style on nodes that already exist.
func applyMapToYAMLNode(mappingNode *yaml.Node, updates map[string]any) {
if mappingNode.Kind != yaml.MappingNode {
return
}
keyIndex := make(map[string]int)
for i := 0; i < len(mappingNode.Content)-1; i += 2 {
keyIndex[mappingNode.Content[i].Value] = i + 1
}
for key, value := range updates {
valIdx, exists := keyIndex[key]
if !exists {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}
valNode := anyToYAMLNode(value)
mappingNode.Content = append(mappingNode.Content, keyNode, valNode)
keyIndex[key] = len(mappingNode.Content) - 1
} else {
existingVal := mappingNode.Content[valIdx]
updateYAMLNodeInPlace(existingVal, value)
}
}
}
// updateYAMLNodeInPlace updates the yaml.Node in place to reflect newValue
// while maximally preserving the original formatting style and comments.
func updateYAMLNodeInPlace(node *yaml.Node, newValue any) {
head, line, foot := node.HeadComment, node.LineComment, node.FootComment
switch v := newValue.(type) {
case map[string]any:
if node.Kind == yaml.MappingNode {
applyMapToYAMLNode(node, v)
return
}
*node = *anyToYAMLNode(v)
case []any:
if node.Kind == yaml.SequenceNode {
style := node.Style
newSeq := anyToYAMLNode(v)
*node = *newSeq
node.Style = style
} else {
*node = *anyToYAMLNode(v)
}
default:
newNode := anyToYAMLNode(v)
if node.Kind == yaml.ScalarNode && newNode.Kind == yaml.ScalarNode {
node.Value = newNode.Value
node.Tag = newNode.Tag
} else {
*node = *newNode
}
}
node.HeadComment, node.LineComment, node.FootComment = head, line, foot
}
// anyToYAMLNode converts a Go value to a yaml.Node.
func anyToYAMLNode(v any) *yaml.Node {
if v == nil {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
}
switch val := v.(type) {
case map[string]any:
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
for k, vv := range val {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}
valNode := anyToYAMLNode(vv)
node.Content = append(node.Content, keyNode, valNode)
}
return node
case []any:
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, item := range val {
node.Content = append(node.Content, anyToYAMLNode(item))
}
return node
case bool:
s := "false"
if val {
s = "true"
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: s}
case int:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(val)}
case int64:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.FormatInt(val, 10)}
case float64:
s := strconv.FormatFloat(val, 'f', -1, 64)
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: s}
case string:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: val}
default:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprintf("%v", v)}
}
}
// apiGetI18n returns i18n translations for the given language.
func apiGetI18n(r *ghttp.Request) {
lang := r.Get("lang").String()
if lang == "" {
lang = "en"
}
fileName := lang + ".yaml"
filePath := "resources/i18n/" + fileName
content, err := i18nFS.ReadFile(filePath)
if err != nil {
r.Response.WriteJsonExit(g.Map{
"code": 0,
"data": g.Map{},
})
return
}
var translations map[string]string
if err = gyaml.DecodeTo(content, &translations); err != nil {
r.Response.WriteJsonExit(g.Map{
"code": 0,
"data": g.Map{},
})
return
}
r.Response.WriteJsonExit(g.Map{
"code": 0,
"data": translations,
})
}
// openBrowser opens the default browser to the given URL.
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
return cmd.Start()
}

View File

@ -412,60 +412,3 @@ func Test_Gen_Dao_Sqlite3(t *testing.T) {
}
})
}
func Test_Gen_Dao_FileNameCaseSnakeFirstUpper(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
var (
err error
db = testDB
table = "sys_i18n_message"
sqlContent = fmt.Sprintf(
gtest.DataContent(`gendao`, `user.tpl.sql`),
table,
)
)
dropTableWithDb(db, table)
array := gstr.SplitAndTrim(sqlContent, ";")
for _, v := range array {
if _, err = db.Exec(ctx, v); err != nil {
t.AssertNil(err)
}
}
defer dropTableWithDb(db, table)
var (
path = gfile.Temp(guid.S())
in = gendao.CGenDaoInput{
Path: path,
Link: link,
Group: "test",
Tables: table,
FileNameCase: "SnakeFirstUpper",
}
)
err = gutil.FillStructWithDefault(&in)
t.AssertNil(err)
err = gfile.Mkdir(path)
t.AssertNil(err)
defer gfile.Remove(path)
err = gfile.Copy(
gtest.DataPath("gendao", "go.mod.txt"),
gfile.Join(path, "go.mod"),
)
t.AssertNil(err)
_, err = gendao.CGenDao{}.Dao(ctx, in)
t.AssertNil(err)
files, err := gfile.ScanDir(path, "*.go", true)
t.AssertNil(err)
t.Assert(files, []string{
filepath.FromSlash(path + "/dao/internal/sys_i18n_message.go"),
filepath.FromSlash(path + "/dao/sys_i18n_message.go"),
filepath.FromSlash(path + "/model/do/sys_i18n_message.go"),
filepath.FromSlash(path + "/model/entity/sys_i18n_message.go"),
})
})
}

View File

@ -33,66 +33,88 @@ import (
)
type (
CGenDao struct{}
// CGenDao is the command handler struct for "gen dao" command.
CGenDao struct{}
// CGenDaoInput defines all input parameters for the "gen dao" command.
// It supports both command-line arguments and configuration file options.
CGenDaoInput struct {
g.Meta `name:"dao" config:"{CGenDaoConfig}" usage:"{CGenDaoUsage}" brief:"{CGenDaoBrief}" eg:"{CGenDaoEg}" ad:"{CGenDaoAd}"`
Path string `name:"path" short:"p" brief:"{CGenDaoBriefPath}" d:"internal"`
Link string `name:"link" short:"l" brief:"{CGenDaoBriefLink}"`
Tables string `name:"tables" short:"t" brief:"{CGenDaoBriefTables}"`
TablesEx string `name:"tablesEx" short:"x" brief:"{CGenDaoBriefTablesEx}"`
ShardingPattern []string `name:"shardingPattern" short:"sp" brief:"{CGenDaoBriefShardingPattern}"`
Group string `name:"group" short:"g" brief:"{CGenDaoBriefGroup}" d:"default"`
Prefix string `name:"prefix" short:"f" brief:"{CGenDaoBriefPrefix}"`
RemovePrefix string `name:"removePrefix" short:"r" brief:"{CGenDaoBriefRemovePrefix}"`
RemoveFieldPrefix string `name:"removeFieldPrefix" short:"rf" brief:"{CGenDaoBriefRemoveFieldPrefix}"`
JsonCase string `name:"jsonCase" short:"j" brief:"{CGenDaoBriefJsonCase}" d:"CamelLower"`
FileNameCase string `name:"fileNameCase" short:"fc" brief:"{CGenDaoBriefFileNameCase}" d:"Snake"`
ImportPrefix string `name:"importPrefix" short:"i" brief:"{CGenDaoBriefImportPrefix}"`
DaoPath string `name:"daoPath" short:"d" brief:"{CGenDaoBriefDaoPath}" d:"dao"`
TablePath string `name:"tablePath" short:"tp" brief:"{CGenDaoBriefTablePath}" d:"table"`
DoPath string `name:"doPath" short:"o" brief:"{CGenDaoBriefDoPath}" d:"model/do"`
EntityPath string `name:"entityPath" short:"e" brief:"{CGenDaoBriefEntityPath}" d:"model/entity"`
TplDaoTablePath string `name:"tplDaoTablePath" short:"t0" brief:"{CGenDaoBriefTplDaoTablePath}"`
TplDaoIndexPath string `name:"tplDaoIndexPath" short:"t1" brief:"{CGenDaoBriefTplDaoIndexPath}"`
TplDaoInternalPath string `name:"tplDaoInternalPath" short:"t2" brief:"{CGenDaoBriefTplDaoInternalPath}"`
TplDaoDoPath string `name:"tplDaoDoPath" short:"t3" brief:"{CGenDaoBriefTplDaoDoPathPath}"`
TplDaoEntityPath string `name:"tplDaoEntityPath" short:"t4" brief:"{CGenDaoBriefTplDaoEntityPath}"`
StdTime bool `name:"stdTime" short:"s" brief:"{CGenDaoBriefStdTime}" orphan:"true"`
WithTime bool `name:"withTime" short:"w" brief:"{CGenDaoBriefWithTime}" orphan:"true"`
GJsonSupport bool `name:"gJsonSupport" short:"n" brief:"{CGenDaoBriefGJsonSupport}" orphan:"true"`
OverwriteDao bool `name:"overwriteDao" short:"v" brief:"{CGenDaoBriefOverwriteDao}" orphan:"true"`
DescriptionTag bool `name:"descriptionTag" short:"c" brief:"{CGenDaoBriefDescriptionTag}" orphan:"true"`
NoJsonTag bool `name:"noJsonTag" short:"k" brief:"{CGenDaoBriefNoJsonTag}" orphan:"true"`
NoModelComment bool `name:"noModelComment" short:"m" brief:"{CGenDaoBriefNoModelComment}" orphan:"true"`
Clear bool `name:"clear" short:"a" brief:"{CGenDaoBriefClear}" orphan:"true"`
GenTable bool `name:"genTable" short:"gt" brief:"{CGenDaoBriefGenTable}" orphan:"true"`
Path string `name:"path" short:"p" brief:"{CGenDaoBriefPath}" d:"internal"` // Base directory path for generated files.
Link string `name:"link" short:"l" brief:"{CGenDaoBriefLink}"` // Database connection string (e.g., "mysql:root:pass@tcp(127.0.0.1:3306)/db").
Tables string `name:"tables" short:"t" brief:"{CGenDaoBriefTables}"` // Comma-separated table names or wildcard patterns to include.
TablesEx string `name:"tablesEx" short:"x" brief:"{CGenDaoBriefTablesEx}"` // Comma-separated table names or wildcard patterns to exclude.
ShardingPattern []string `name:"shardingPattern" short:"sp" brief:"{CGenDaoBriefShardingPattern}"` // Patterns for sharding tables (e.g., "users_?" merges users_001, users_002 into one dao).
Group string `name:"group" short:"g" brief:"{CGenDaoBriefGroup}" d:"default"` // Database configuration group name for ORM instance.
Prefix string `name:"prefix" short:"f" brief:"{CGenDaoBriefPrefix}"` // Prefix to add to all generated table names.
RemovePrefix string `name:"removePrefix" short:"r" brief:"{CGenDaoBriefRemovePrefix}"` // Comma-separated prefixes to remove from table names.
RemoveFieldPrefix string `name:"removeFieldPrefix" short:"rf" brief:"{CGenDaoBriefRemoveFieldPrefix}"` // Comma-separated prefixes to remove from field names.
JsonCase string `name:"jsonCase" short:"j" brief:"{CGenDaoBriefJsonCase}" d:"CamelLower"` // Naming convention for JSON tags (e.g., CamelLower, Snake).
ImportPrefix string `name:"importPrefix" short:"i" brief:"{CGenDaoBriefImportPrefix}"` // Custom Go import path prefix for generated files.
DaoPath string `name:"daoPath" short:"d" brief:"{CGenDaoBriefDaoPath}" d:"dao"` // Sub-directory under Path for dao files.
TablePath string `name:"tablePath" short:"tp" brief:"{CGenDaoBriefTablePath}" d:"table"` // Sub-directory under Path for table field definition files.
DoPath string `name:"doPath" short:"o" brief:"{CGenDaoBriefDoPath}" d:"model/do"` // Sub-directory under Path for DO (Data Object) files.
EntityPath string `name:"entityPath" short:"e" brief:"{CGenDaoBriefEntityPath}" d:"model/entity"` // Sub-directory under Path for entity struct files.
TplDaoTablePath string `name:"tplDaoTablePath" short:"t0" brief:"{CGenDaoBriefTplDaoTablePath}"` // Custom template file for dao table generation.
TplDaoIndexPath string `name:"tplDaoIndexPath" short:"t1" brief:"{CGenDaoBriefTplDaoIndexPath}"` // Custom template file for dao index generation.
TplDaoInternalPath string `name:"tplDaoInternalPath" short:"t2" brief:"{CGenDaoBriefTplDaoInternalPath}"` // Custom template file for dao internal generation.
TplDaoDoPath string `name:"tplDaoDoPath" short:"t3" brief:"{CGenDaoBriefTplDaoDoPathPath}"` // Custom template file for DO generation.
TplDaoEntityPath string `name:"tplDaoEntityPath" short:"t4" brief:"{CGenDaoBriefTplDaoEntityPath}"` // Custom template file for entity generation.
StdTime bool `name:"stdTime" short:"s" brief:"{CGenDaoBriefStdTime}" orphan:"true"` // Use stdlib time.Time instead of gtime.Time for time fields.
WithTime bool `name:"withTime" short:"w" brief:"{CGenDaoBriefWithTime}" orphan:"true"` // Add creation timestamp to generated file headers.
GJsonSupport bool `name:"gJsonSupport" short:"n" brief:"{CGenDaoBriefGJsonSupport}" orphan:"true"` // Use *gjson.Json instead of string for JSON fields.
OverwriteDao bool `name:"overwriteDao" short:"v" brief:"{CGenDaoBriefOverwriteDao}" orphan:"true"` // Overwrite existing dao files (both index and internal).
DescriptionTag bool `name:"descriptionTag" short:"c" brief:"{CGenDaoBriefDescriptionTag}" orphan:"true"` // Add description struct tag with field comment.
NoJsonTag bool `name:"noJsonTag" short:"k" brief:"{CGenDaoBriefNoJsonTag}" orphan:"true"` // Omit json struct tags from generated structs.
NoModelComment bool `name:"noModelComment" short:"m" brief:"{CGenDaoBriefNoModelComment}" orphan:"true"` // Omit inline comments from generated struct fields.
Clear bool `name:"clear" short:"a" brief:"{CGenDaoBriefClear}" orphan:"true"` // Delete generated files that no longer correspond to database tables.
GenTable bool `name:"genTable" short:"gt" brief:"{CGenDaoBriefGenTable}" orphan:"true"` // Enable generation of table field definition files.
SqlDir string `name:"sqlDir" short:"sd" brief:"{CGenDaoBriefSqlDir}"` // Directory of SQL DDL files for offline generation (no DB connection needed).
SqlType string `name:"sqlType" short:"st" brief:"{CGenDaoBriefSqlType}" d:"mysql"` // SQL dialect when using SqlDir (mysql, pgsql, mssql, oracle, sqlite).
TypeMapping map[DBFieldTypeName]CustomAttributeType `name:"typeMapping" short:"y" brief:"{CGenDaoBriefTypeMapping}" orphan:"true"`
// TypeMapping maps database field type names to custom Go types.
// For example, mapping "decimal" to "float64" or "uuid" to "uuid.UUID".
TypeMapping map[DBFieldTypeName]CustomAttributeType `name:"typeMapping" short:"y" brief:"{CGenDaoBriefTypeMapping}" orphan:"true"`
// FieldMapping maps specific table.field combinations to custom Go types.
// For example, mapping "user.balance" to "decimal.Decimal".
FieldMapping map[DBTableFieldName]CustomAttributeType `name:"fieldMapping" short:"fm" brief:"{CGenDaoBriefFieldMapping}" orphan:"true"`
// internal usage purpose.
// genItems tracks all generated file paths and directories for cleanup purposes.
genItems *CGenDaoInternalGenItems
}
// CGenDaoOutput is the output of the "gen dao" command (currently empty).
CGenDaoOutput struct{}
// CGenDaoInternalInput extends CGenDaoInput with runtime-resolved fields
// used during the actual generation process.
CGenDaoInternalInput struct {
CGenDaoInput
DB gdb.DB
TableNames []string
NewTableNames []string
ShardingTableSet *gset.StrSet
DB gdb.DB // Database connection instance (nil in SQL file mode).
TableNames []string // Original table names from database or SQL files.
NewTableNames []string // Processed table names after prefix removal and sharding.
ShardingTableSet *gset.StrSet // Set of table names identified as sharding tables.
// TableFieldsMap stores pre-parsed table fields from SQL files.
// When this is set (SQL file mode), DB may be nil.
TableFieldsMap map[string]map[string]*gdb.TableField
}
DBTableFieldName = string
DBFieldTypeName = string
// DBTableFieldName is the fully-qualified field name in "table.field" format.
DBTableFieldName = string
// DBFieldTypeName is the database column type name (e.g., "varchar", "decimal").
DBFieldTypeName = string
// CustomAttributeType defines a custom Go type mapping with its import path.
CustomAttributeType struct {
Type string `brief:"custom attribute type name"`
Import string `brief:"custom import for this type"`
Type string `brief:"custom attribute type name"` // Go type name (e.g., "decimal.Decimal").
Import string `brief:"custom import for this type"` // Go import path (e.g., "github.com/shopspring/decimal").
}
)
var (
createdAt = gtime.Now()
tplView = gview.New()
createdAt = gtime.Now() // Timestamp captured at program start, used in generated file headers.
tplView = gview.New() // Shared template view instance for rendering all Go file templates.
// defaultTypeMapping provides built-in type mappings from database types to Go types.
// User-provided TypeMapping takes precedence over these defaults.
defaultTypeMapping = map[DBFieldTypeName]CustomAttributeType{
"decimal": {
Type: "float64",
@ -112,7 +134,8 @@ var (
},
}
// tablewriter Options
// twRenderer configures the tablewriter to render without borders or separators,
// producing clean aligned text output for generated Go source code.
twRenderer = tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
Borders: tw.Border{Top: tw.Off, Bottom: tw.Off, Left: tw.Off, Right: tw.Off},
Settings: tw.Settings{
@ -127,9 +150,17 @@ var (
})
)
// Dao is the main entry point for the "gen dao" command.
// It dispatches to the appropriate generation mode based on input:
// - SQL file mode (SqlDir is set): generates from DDL files without database connection.
// - Link mode (Link is set): uses a direct database connection string.
// - Config mode: reads database configuration from the application config file.
func (c CGenDao) Dao(ctx context.Context, in CGenDaoInput) (out *CGenDaoOutput, err error) {
in.genItems = newCGenDaoInternalGenItems()
if in.Link != "" {
if in.SqlDir != "" {
// SQL file mode: generate from SQL DDL files without database connection.
doGenDaoFromSQLFiles(ctx, in)
} else if in.Link != "" {
doGenDaoForArray(ctx, -1, in)
} else if g.Cfg().Available(ctx) {
v := g.Cfg().MustGet(ctx, CGenDaoConfig)
@ -148,7 +179,11 @@ func (c CGenDao) Dao(ctx context.Context, in CGenDaoInput) (out *CGenDaoOutput,
return
}
// doGenDaoForArray implements the "gen dao" command for configuration array.
// doGenDaoForArray implements the "gen dao" command for a single configuration entry.
// When index >= 0, it reads configuration from the array at that index.
// When index < 0, it uses the input as-is (for Link mode or single config mode).
// It performs the full generation pipeline: connect to DB, resolve tables,
// apply sharding patterns, and generate dao/table/do/entity files.
func doGenDaoForArray(ctx context.Context, index int, in CGenDaoInput) {
var (
err error
@ -333,6 +368,10 @@ func doGenDaoForArray(ctx context.Context, index int, in CGenDaoInput) {
in.genItems.SetClear(in.Clear)
}
// getImportPartContent analyzes the generated Go source code and builds the import block.
// It automatically detects usage of gtime.Time, time.Time, and gjson.Json in the source,
// and includes the corresponding import paths. Additional custom imports (from TypeMapping
// or FieldMapping) are appended and their dependencies are resolved via "go get" if needed.
func getImportPartContent(ctx context.Context, source string, isDo bool, appendImports []string) string {
var packageImportsArray = garray.NewStrArray()
if isDo {
@ -386,6 +425,9 @@ func getImportPartContent(ctx context.Context, source string, isDo bool, appendI
return packageImportsStr
}
// assignDefaultVar sets the default template variables for datetime strings
// used in generated file headers. The creation timestamp is only included
// when WithTime is enabled in the input configuration.
func assignDefaultVar(view *gview.View, in CGenDaoInternalInput) {
var (
tplCreatedAtDatetimeStr string
@ -400,6 +442,8 @@ func assignDefaultVar(view *gview.View, in CGenDaoInternalInput) {
})
}
// sortFieldKeyForDao returns field names sorted by their Index in the TableField map.
// This preserves the original column order as defined in the database table schema.
func sortFieldKeyForDao(fieldMap map[string]*gdb.TableField) []string {
names := make(map[int]string)
for _, field := range fieldMap {
@ -424,6 +468,20 @@ func sortFieldKeyForDao(fieldMap map[string]*gdb.TableField) []string {
return result
}
// getTableFields retrieves table fields either from the pre-parsed TableFieldsMap (SQL file mode)
// or from the database connection. This abstracts the data source for generation functions.
func getTableFields(ctx context.Context, in CGenDaoInternalInput, tableName string) (map[string]*gdb.TableField, error) {
if in.TableFieldsMap != nil {
if fields, ok := in.TableFieldsMap[tableName]; ok {
return fields, nil
}
return nil, fmt.Errorf("table '%s' not found in SQL files", tableName)
}
return in.DB.TableFields(ctx, tableName)
}
// getTemplateFromPathOrDefault returns the template content from the given file path.
// If the file path is empty or the file has no content, it falls back to the default template.
func getTemplateFromPathOrDefault(filePath string, def string) string {
if filePath != "" {
if contents := gfile.GetContents(filePath); contents != "" {
@ -490,3 +548,130 @@ func filterTablesByPatterns(allTables []string, patterns []string) []string {
}
return result
}
// doGenDaoFromSQLFiles implements the "gen dao" command for SQL file mode.
// It parses DDL SQL files to obtain table structures without requiring a database connection.
func doGenDaoFromSQLFiles(ctx context.Context, in CGenDaoInput) {
if dirRealPath := gfile.RealPath(in.Path); dirRealPath == "" {
mlog.Fatalf(`path "%s" does not exist`, in.Path)
}
if dirRealPath := gfile.RealPath(in.SqlDir); dirRealPath == "" {
mlog.Fatalf(`SQL directory "%s" does not exist`, in.SqlDir)
}
dialect := SQLDialect(strings.ToLower(in.SqlType))
tableNames, tableFieldsMap := ParseSQLFilesFromDir(in.SqlDir, dialect)
removePrefixArray := gstr.SplitAndTrim(in.RemovePrefix, ",")
// Table filtering by name patterns.
if in.Tables != "" {
inputTables := gstr.SplitAndTrim(in.Tables, ",")
var hasPattern bool
for _, t := range inputTables {
if containsWildcard(t) {
hasPattern = true
break
}
}
if hasPattern {
tableNames = filterTablesByPatterns(tableNames, inputTables)
} else {
tableNames = inputTables
}
}
// Table excluding.
if in.TablesEx != "" {
array := garray.NewStrArrayFrom(tableNames)
for _, p := range gstr.SplitAndTrim(in.TablesEx, ",") {
if containsWildcard(p) {
regPattern := "^" + patternToRegex(p) + "$"
for _, v := range array.Clone().Slice() {
if gregex.IsMatchString(regPattern, v) {
array.RemoveValue(v)
}
}
} else {
array.RemoveValue(p)
}
}
tableNames = array.Slice()
}
// merge default typeMapping.
if in.TypeMapping == nil {
in.TypeMapping = defaultTypeMapping
} else {
for key, typeMapping := range defaultTypeMapping {
if _, ok := in.TypeMapping[key]; !ok {
in.TypeMapping[key] = typeMapping
}
}
}
// Process table names (prefix removal, sharding, etc.)
var (
newTableNames = make([]string, len(tableNames))
shardingNewTableSet = gset.NewStrSet()
)
sortedShardingPatterns := make([]string, len(in.ShardingPattern))
copy(sortedShardingPatterns, in.ShardingPattern)
sort.Slice(sortedShardingPatterns, func(i, j int) bool {
return len(sortedShardingPatterns[i]) > len(sortedShardingPatterns[j])
})
for i, tableName := range tableNames {
newTableName := tableName
for _, v := range removePrefixArray {
newTableName = gstr.TrimLeftStr(newTableName, v, 1)
}
if len(sortedShardingPatterns) > 0 {
for _, pattern := range sortedShardingPatterns {
var (
match []string
regPattern = gstr.Replace(pattern, "?", `(.+)`)
err error
)
match, err = gregex.MatchString(regPattern, newTableName)
if err != nil {
mlog.Fatalf(`invalid sharding pattern "%s": %+v`, pattern, err)
}
if len(match) < 2 {
continue
}
newTableName = gstr.Replace(pattern, "?", "")
newTableName = gstr.Trim(newTableName, `_.-`)
if shardingNewTableSet.Contains(newTableName) {
tableNames[i] = ""
break
}
shardingNewTableSet.Add(in.Prefix + newTableName)
break
}
}
newTableName = in.Prefix + newTableName
if tableNames[i] != "" {
newTableNames[i] = newTableName
}
}
tableNames = garray.NewStrArrayFrom(tableNames).FilterEmpty().Slice()
newTableNames = garray.NewStrArrayFrom(newTableNames).FilterEmpty().Slice()
in.genItems.Scale()
internalInput := CGenDaoInternalInput{
CGenDaoInput: in,
DB: nil,
TableNames: tableNames,
NewTableNames: newTableNames,
ShardingTableSet: shardingNewTableSet,
TableFieldsMap: tableFieldsMap,
}
// Generate all files using the same flow as database mode.
generateDao(ctx, internalInput)
generateTable(ctx, internalInput)
generateDo(ctx, internalInput)
generateEntity(ctx, internalInput)
in.genItems.SetClear(in.Clear)
}

View File

@ -13,6 +13,10 @@ import (
"github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog"
)
// doClear performs cleanup of stale generated files across all generation items.
// It collects all generated file paths from all items, then for each item with
// Clear enabled, removes any .go files in its directories that are NOT in the
// generated file list. This ensures files for dropped/removed tables are cleaned up.
func doClear(items *CGenDaoInternalGenItems) {
var allGeneratedFilePaths = make([]string, 0)
for _, item := range items.Items {
@ -29,6 +33,10 @@ func doClear(items *CGenDaoInternalGenItems) {
}
}
// doClearItem removes stale .go files for a single generation item.
// It scans all storage directories for .go files and deletes any file
// that is not in the allGeneratedFilePaths list (i.e., no longer corresponds
// to an existing database table).
func doClearItem(item CGenDaoInternalGenItem, allGeneratedFilePaths []string) {
var generatedFilePaths = make([]string, 0)
for _, dirPath := range item.StorageDirPaths {

View File

@ -26,6 +26,9 @@ import (
"github.com/gogf/gf/cmd/gf/v2/internal/utility/utils"
)
// generateDao generates dao files (index + internal) for all tables in the input.
// It creates the dao directory structure and iterates over each table to generate
// individual dao files via generateDaoSingle.
func generateDao(ctx context.Context, in CGenDaoInternalInput) {
var (
dirPathDao = gfile.Join(in.Path, in.DaoPath)
@ -48,28 +51,27 @@ func generateDao(ctx context.Context, in CGenDaoInternalInput) {
}
}
// generateDaoSingleInput holds all parameters needed to generate dao files for a single table.
type generateDaoSingleInput struct {
CGenDaoInternalInput
// TableName specifies the table name of the table.
TableName string
// NewTableName specifies the prefix-stripped or custom edited name of the table.
NewTableName string
DirPathDao string
DirPathDaoInternal string
IsSharding bool
TableName string // Original table name as it exists in the database.
NewTableName string // Processed table name after prefix removal and sharding.
DirPathDao string // Directory path for the dao index files.
DirPathDaoInternal string // Directory path for the dao internal implementation files.
IsSharding bool // Whether this table is a sharding table (merged from multiple physical tables).
}
// generateDaoSingle generates the dao and model content of given table.
func generateDaoSingle(ctx context.Context, in generateDaoSingleInput) {
// Generating table data preparing.
fieldMap, err := in.DB.TableFields(ctx, in.TableName)
fieldMap, err := getTableFields(ctx, in.CGenDaoInternalInput, in.TableName)
if err != nil {
mlog.Fatalf(`fetching tables fields failed for table "%s": %+v`, in.TableName, err)
}
var (
tableNameCamelCase = formatFieldName(in.NewTableName, FieldNameCaseCamel)
tableNameCamelLowerCase = formatFieldName(in.NewTableName, FieldNameCaseCamelLower)
fileName = formatFileName(in.NewTableName, in.FileNameCase)
tableNameSnakeCase = gstr.CaseSnake(in.NewTableName)
importPrefix = in.ImportPrefix
)
if importPrefix == "" {
@ -78,6 +80,13 @@ func generateDaoSingle(ctx context.Context, in generateDaoSingleInput) {
importPrefix = gstr.Join(g.SliceStr{importPrefix, in.DaoPath}, "/")
}
fileName := gstr.Trim(tableNameSnakeCase, "-_.")
if len(fileName) > 5 && fileName[len(fileName)-5:] == "_test" {
// Add suffix to avoid the table name which contains "_test",
// which would make the go file a testing file.
fileName += "_table"
}
// dao - index
generateDaoIndex(generateDaoIndexInput{
generateDaoSingleInput: in,
@ -98,14 +107,21 @@ func generateDaoSingle(ctx context.Context, in generateDaoSingleInput) {
})
}
// generateDaoIndexInput holds parameters for generating the dao index file.
// The index file provides the public API (exported struct and constructor)
// for accessing the DAO, delegating to the internal implementation.
type generateDaoIndexInput struct {
generateDaoSingleInput
TableNameCamelCase string
TableNameCamelLowerCase string
ImportPrefix string
FileName string
TableNameCamelCase string // CamelCase version of the table name (e.g., "UserDetail").
TableNameCamelLowerCase string // camelCase version of the table name (e.g., "userDetail").
ImportPrefix string // Go import path prefix for the dao package.
FileName string // Output file name (without extension).
}
// generateDaoIndex generates the dao index file for a single table.
// The index file is the public-facing dao file that users import directly.
// It will NOT overwrite an existing file unless OverwriteDao is enabled,
// allowing users to customize the index file without losing changes.
func generateDaoIndex(in generateDaoIndexInput) {
path := filepath.FromSlash(gfile.Join(in.DirPathDao, in.FileName+".go"))
// It should add path to result slice whenever it would generate the path file or not.
@ -140,15 +156,21 @@ func generateDaoIndex(in generateDaoIndexInput) {
}
}
// generateDaoInternalInput holds parameters for generating the dao internal file.
// The internal file contains the actual DAO implementation with column definitions
// and is always overwritten on regeneration.
type generateDaoInternalInput struct {
generateDaoSingleInput
TableNameCamelCase string
TableNameCamelLowerCase string
ImportPrefix string
FileName string
FieldMap map[string]*gdb.TableField
TableNameCamelCase string // CamelCase version of the table name.
TableNameCamelLowerCase string // camelCase version of the table name.
ImportPrefix string // Go import path prefix for the dao package.
FileName string // Output file name (without extension).
FieldMap map[string]*gdb.TableField // Map of column name to field metadata.
}
// generateDaoInternal generates the dao internal implementation file for a single table.
// This file is always regenerated (overwritten) and contains the Columns struct definition
// with column name constants and their string value assignments.
func generateDaoInternal(in generateDaoInternalInput) {
var (
ctx = context.Background()

View File

@ -22,6 +22,10 @@ import (
"github.com/gogf/gf/cmd/gf/v2/internal/utility/utils"
)
// generateDo generates DO (Data Object) files for all tables.
// DO structs use "any" type for all scalar fields (replacing concrete types),
// enabling flexible query building with the g.Meta `orm:"do:true"` tag.
// Pointer, slice, and map types are preserved as-is.
func generateDo(ctx context.Context, in CGenDaoInternalInput) {
var dirPathDo = filepath.FromSlash(gfile.Join(in.Path, in.DoPath))
in.genItems.AppendDirPath(dirPathDo)
@ -30,13 +34,13 @@ func generateDo(ctx context.Context, in CGenDaoInternalInput) {
in.NoModelComment = false
// Model content.
for i, tableName := range in.TableNames {
fieldMap, err := in.DB.TableFields(ctx, tableName)
fieldMap, err := getTableFields(ctx, in, tableName)
if err != nil {
mlog.Fatalf("fetching tables fields failed for table '%s':\n%v", tableName, err)
}
var (
newTableName = in.NewTableNames[i]
doFilePath = gfile.Join(dirPathDo, formatFileName(newTableName, in.FileNameCase)+".go")
doFilePath = gfile.Join(dirPathDo, gstr.CaseSnake(newTableName)+".go")
structDefinition, _ = generateStructDefinition(ctx, generateStructDefinitionInput{
CGenDaoInternalInput: in,
TableName: tableName,
@ -75,6 +79,9 @@ func generateDo(ctx context.Context, in CGenDaoInternalInput) {
}
}
// generateDoContent renders the DO file content using the template engine.
// It assembles template variables including package imports, struct definition,
// and metadata, then parses the DO template to produce the final file content.
func generateDoContent(
ctx context.Context, in CGenDaoInternalInput, tableName, tableNameCamelCase, structDefine string,
) string {

View File

@ -13,25 +13,29 @@ import (
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gview"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/cmd/gf/v2/internal/consts"
"github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog"
"github.com/gogf/gf/cmd/gf/v2/internal/utility/utils"
)
// generateEntity generates entity struct files for all tables.
// Entity structs represent database table rows with concrete Go types,
// including orm tags for field-to-column mapping and json tags for serialization.
func generateEntity(ctx context.Context, in CGenDaoInternalInput) {
var dirPathEntity = gfile.Join(in.Path, in.EntityPath)
in.genItems.AppendDirPath(dirPathEntity)
// Model content.
for i, tableName := range in.TableNames {
fieldMap, err := in.DB.TableFields(ctx, tableName)
fieldMap, err := getTableFields(ctx, in, tableName)
if err != nil {
mlog.Fatalf("fetching tables fields failed for table '%s':\n%v", tableName, err)
}
var (
newTableName = in.NewTableNames[i]
entityFilePath = filepath.FromSlash(gfile.Join(dirPathEntity, formatFileName(newTableName, in.FileNameCase)+".go"))
entityFilePath = filepath.FromSlash(gfile.Join(dirPathEntity, gstr.CaseSnake(newTableName)+".go"))
structDefinition, appendImports = generateStructDefinition(ctx, generateStructDefinitionInput{
CGenDaoInternalInput: in,
TableName: tableName,
@ -59,6 +63,9 @@ func generateEntity(ctx context.Context, in CGenDaoInternalInput) {
}
}
// generateEntityContent renders the entity file content using the template engine.
// It assembles template variables and parses the entity template to produce
// the final Go source file content with proper imports and struct definition.
func generateEntityContent(
ctx context.Context, in CGenDaoInternalInput, tableName, tableNameCamelCase, structDefine string, appendImports []string,
) string {

View File

@ -7,17 +7,25 @@
package gendao
type (
// CGenDaoInternalGenItems tracks generation state across multiple configuration entries.
// Each configuration entry (e.g., different database links in the config array)
// gets its own CGenDaoInternalGenItem via Scale(). The index field points to the
// current active item.
CGenDaoInternalGenItems struct {
index int
Items []CGenDaoInternalGenItem
index int // Index of the current active generation item.
Items []CGenDaoInternalGenItem // List of all generation items, one per config entry.
}
// CGenDaoInternalGenItem tracks generated files and directories for a single
// configuration entry. Used by the Clear feature to identify and remove stale files.
CGenDaoInternalGenItem struct {
Clear bool
StorageDirPaths []string
GeneratedFilePaths []string
Clear bool // Whether to clear stale files for this item.
StorageDirPaths []string // Directories where generated files are stored (dao, do, entity, table).
GeneratedFilePaths []string // All file paths generated in this run.
}
)
// newCGenDaoInternalGenItems creates a new generation items tracker with an empty item list.
func newCGenDaoInternalGenItems() *CGenDaoInternalGenItems {
return &CGenDaoInternalGenItems{
index: -1,
@ -25,6 +33,8 @@ func newCGenDaoInternalGenItems() *CGenDaoInternalGenItems {
}
}
// Scale adds a new generation item and advances the index to it.
// Must be called once per configuration entry before generating files.
func (i *CGenDaoInternalGenItems) Scale() {
i.Items = append(i.Items, CGenDaoInternalGenItem{
StorageDirPaths: make([]string, 0),
@ -34,10 +44,12 @@ func (i *CGenDaoInternalGenItems) Scale() {
i.index++
}
// SetClear enables or disables the clear (stale file removal) flag for the current item.
func (i *CGenDaoInternalGenItems) SetClear(clear bool) {
i.Items[i.index].Clear = clear
}
// AppendDirPath records a directory path used for storing generated files in the current item.
func (i *CGenDaoInternalGenItems) AppendDirPath(storageDirPath string) {
i.Items[i.index].StorageDirPaths = append(
i.Items[i.index].StorageDirPaths,
@ -45,6 +57,7 @@ func (i *CGenDaoInternalGenItems) AppendDirPath(storageDirPath string) {
)
}
// AppendGeneratedFilePath records a file path that was generated in the current item.
func (i *CGenDaoInternalGenItems) AppendGeneratedFilePath(generatedFilePath string) {
i.Items[i.index].GeneratedFilePaths = append(
i.Items[i.index].GeneratedFilePaths,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
)
// MSSQLParser implements SQLParser for SQL Server (T-SQL) DDL.
type MSSQLParser struct{}
// ParseCreateTable parses a single MSSQL CREATE TABLE statement.
func (p *MSSQLParser) ParseCreateTable(stmt string) (string, map[string]*gdb.TableField, error) {
body, _, ok := extractBodyAndTrailing(stmt)
if !ok {
return "", nil, nil
}
parenIdx := strings.Index(stmt, "(")
header := stmt[:parenIdx]
tableName := extractTableName(header)
if tableName == "" {
return "", nil, fmt.Errorf("cannot extract table name from: %s", header)
}
columnDefs := splitColumns(body)
fields := make(map[string]*gdb.TableField)
pkColumns := findPrimaryKeysFromConstraints(columnDefs)
fieldIndex := 0
for _, def := range columnDefs {
def = strings.TrimSpace(def)
if def == "" {
continue
}
firstWord := strings.ToUpper(strings.Fields(def)[0])
if isConstraintKeyword(firstWord) {
continue
}
field, err := p.parseColumnDef(def, fieldIndex)
if err != nil {
continue
}
if field != nil {
fields[field.Name] = field
fieldIndex++
}
}
for _, pkCol := range pkColumns {
if f, ok := fields[pkCol]; ok {
f.Key = "PRI"
}
}
return tableName, fields, nil
}
// ParseAlterTable parses MSSQL ALTER TABLE statements.
func (p *MSSQLParser) ParseAlterTable(stmt string, tables map[string]map[string]*gdb.TableField) error {
return parseAlterTableCommon(stmt, tables, p.parseColumnDef)
}
// ParseComment parses EXEC sp_addextendedproperty to extract column comments.
func (p *MSSQLParser) ParseComment(stmt string, tables map[string]map[string]*gdb.TableField) {
upper := strings.ToUpper(strings.TrimSpace(stmt))
if !strings.Contains(upper, "SP_ADDEXTENDEDPROPERTY") ||
!strings.Contains(upper, "MS_DESCRIPTION") {
return
}
// Extract quoted string values
var values []string
inQuote := false
var current strings.Builder
for i := 0; i < len(stmt); i++ {
ch := stmt[i]
if ch == '\'' {
if inQuote {
if i+1 < len(stmt) && stmt[i+1] == '\'' {
current.WriteByte('\'')
i++
continue
}
values = append(values, current.String())
current.Reset()
inQuote = false
} else {
inQuote = true
}
} else if inQuote {
current.WriteByte(ch)
}
}
if len(values) < 8 {
return
}
var (
comment string
tableName string
columnName string
)
for i := 0; i < len(values)-1; i++ {
switch strings.ToUpper(values[i]) {
case "MS_DESCRIPTION":
comment = values[i+1]
case "TABLE":
tableName = values[i+1]
case "COLUMN":
columnName = values[i+1]
}
}
if tableName != "" && columnName != "" && comment != "" {
if fields, ok := tables[tableName]; ok {
if field, ok := fields[columnName]; ok {
field.Comment = comment
}
}
}
}
// parseColumnDef parses a single MSSQL column definition string into a TableField.
// It handles MSSQL-specific syntax including bracket-quoted identifiers and
// type parameters like varchar(max).
func (p *MSSQLParser) parseColumnDef(def string, index int) (*gdb.TableField, error) {
tokens := mysqlTokenize(def)
if len(tokens) < 2 {
return nil, fmt.Errorf("invalid column definition: %s", def)
}
field := &gdb.TableField{
Index: index,
Name: unquoteIdentifier(tokens[0]),
Null: true,
}
field.Type = tokens[1]
rest := ""
if len(tokens) > 2 {
rest = strings.Join(tokens[2:], " ")
}
if !strings.Contains(field.Type, "(") && strings.HasPrefix(strings.TrimSpace(rest), "(") {
end := strings.Index(rest, ")")
if end >= 0 {
field.Type += rest[:end+1]
rest = strings.TrimSpace(rest[end+1:])
}
}
p.parseColumnAttributes(field, rest)
return field, nil
}
// parseColumnAttributes parses MSSQL column constraint keywords including
// NOT NULL, NULL, PRIMARY KEY, UNIQUE, IDENTITY (auto-increment), and DEFAULT.
func (p *MSSQLParser) parseColumnAttributes(field *gdb.TableField, attrs string) {
words := strings.Fields(attrs)
upperWords := strings.Fields(strings.ToUpper(attrs))
for i := 0; i < len(upperWords); i++ {
switch upperWords[i] {
case "NOT":
if i+1 < len(upperWords) && upperWords[i+1] == "NULL" {
field.Null = false
i++
}
case "NULL":
field.Null = true
case "PRIMARY":
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
field.Key = "PRI"
i++
}
case "UNIQUE":
if field.Key == "" {
field.Key = "UNI"
}
case "IDENTITY":
field.Extra = "auto_increment"
if i+1 < len(words) && strings.HasPrefix(words[i+1], "(") {
i++
}
default:
if strings.HasPrefix(upperWords[i], "IDENTITY(") || strings.HasPrefix(upperWords[i], "IDENTITY (") {
field.Extra = "auto_increment"
}
case "DEFAULT":
if i+1 < len(words) {
defaultVal, _ := extractDefaultValue("DEFAULT " + strings.Join(words[i+1:], " "))
field.Default = defaultVal
if defaultVal != nil {
i++
}
}
}
}
}

View File

@ -0,0 +1,72 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_MSSQL_CreateTable_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MSSQLParser{}
sql := `
CREATE TABLE [dbo].[users] (
[id] INT IDENTITY(1,1) NOT NULL,
[name] NVARCHAR(100) NOT NULL,
[email] NVARCHAR(200) NULL,
[balance] DECIMAL(18,2) DEFAULT 0,
[created_at] DATETIME2 NOT NULL DEFAULT GETDATE(),
CONSTRAINT [PK_users] PRIMARY KEY CLUSTERED ([id])
);
EXEC sp_addextendedproperty 'MS_Description', 'User ID', 'SCHEMA', 'dbo', 'TABLE', 'users', 'COLUMN', 'id';
EXEC sp_addextendedproperty 'MS_Description', 'User name', 'SCHEMA', 'dbo', 'TABLE', 'users', 'COLUMN', 'name';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 5)
t.Assert(fields["id"].Extra, "auto_increment")
t.Assert(fields["id"].Null, false)
t.Assert(fields["id"].Key, "PRI")
t.Assert(fields["id"].Comment, "User ID")
t.Assert(fields["name"].Comment, "User name")
t.Assert(fields["name"].Null, false)
t.Assert(fields["email"].Null, true)
})
}
func Test_MSSQL_AlterTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MSSQLParser{}
sql := `
CREATE TABLE users (
id INT IDENTITY(1,1) NOT NULL,
name NVARCHAR(100) NOT NULL,
CONSTRAINT PK_users PRIMARY KEY (id)
);
ALTER TABLE users ADD email NVARCHAR(200) NULL;
ALTER TABLE users DROP COLUMN name;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 2) // id, email
_, ok := fields["name"]
t.Assert(ok, false)
t.Assert(fields["email"].Null, true)
})
}

View File

@ -0,0 +1,199 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
)
// MySQLParser implements SQLParser for MySQL/MariaDB/TiDB DDL.
type MySQLParser struct{}
// ParseCreateTable parses a single MySQL CREATE TABLE statement.
func (p *MySQLParser) ParseCreateTable(stmt string) (string, map[string]*gdb.TableField, error) {
body, trailing, ok := extractBodyAndTrailing(stmt)
if !ok {
return "", nil, nil
}
parenIdx := strings.Index(stmt, "(")
header := stmt[:parenIdx]
tableName := extractTableName(header)
if tableName == "" {
return "", nil, fmt.Errorf("cannot extract table name from: %s", header)
}
columnDefs := splitColumns(body)
fields := make(map[string]*gdb.TableField)
pkColumns := findPrimaryKeysFromConstraints(columnDefs)
fieldIndex := 0
for _, def := range columnDefs {
def = strings.TrimSpace(def)
if def == "" {
continue
}
firstWord := strings.ToUpper(strings.Fields(def)[0])
if isConstraintKeyword(firstWord) {
continue
}
field, err := p.parseColumnDef(def, fieldIndex)
if err != nil {
continue
}
if field != nil {
fields[field.Name] = field
fieldIndex++
}
}
for _, pkCol := range pkColumns {
if f, ok := fields[pkCol]; ok {
f.Key = "PRI"
}
}
// Extract inline comments from trailing table options (not used for field generation)
_ = trailing
return tableName, fields, nil
}
// ParseAlterTable parses MySQL ALTER TABLE statements.
func (p *MySQLParser) ParseAlterTable(stmt string, tables map[string]map[string]*gdb.TableField) error {
return parseAlterTableCommon(stmt, tables, p.parseColumnDef)
}
// ParseComment handles MySQL-style comments (inline COMMENT keyword is handled in parseColumnDef).
func (p *MySQLParser) ParseComment(stmt string, tables map[string]map[string]*gdb.TableField) {
// MySQL uses inline COMMENT 'xxx' in column definitions,
// which is already handled by parseColumnDef. No separate COMMENT ON statement.
}
// parseColumnDef parses a single MySQL column definition string into a TableField.
// It extracts the column name, data type (including UNSIGNED modifier), and delegates
// attribute parsing (NULL, DEFAULT, PRIMARY KEY, COMMENT, etc.) to parseColumnAttributes.
func (p *MySQLParser) parseColumnDef(def string, index int) (*gdb.TableField, error) {
tokens := mysqlTokenize(def)
if len(tokens) < 2 {
return nil, fmt.Errorf("invalid column definition: %s", def)
}
field := &gdb.TableField{
Index: index,
Name: unquoteIdentifier(tokens[0]),
Null: true,
}
typeStr := tokens[1]
rest := ""
if len(tokens) > 2 {
rest = strings.Join(tokens[2:], " ")
}
// Check if rest starts with '(' meaning the type params are in rest
if !strings.Contains(typeStr, "(") && strings.HasPrefix(strings.TrimSpace(rest), "(") {
endParen := strings.Index(rest, ")")
if endParen >= 0 {
typeStr += rest[:endParen+1]
rest = strings.TrimSpace(rest[endParen+1:])
}
}
field.Type = typeStr
// Handle UNSIGNED
upperRest := strings.ToUpper(rest)
if strings.HasPrefix(upperRest, "UNSIGNED") {
field.Type += " unsigned"
rest = strings.TrimSpace(rest[8:])
}
p.parseColumnAttributes(field, rest)
return field, nil
}
// parseColumnAttributes parses MySQL column constraint keywords from the attribute string
// following the column type. It handles NOT NULL, NULL, PRIMARY KEY, UNIQUE, AUTO_INCREMENT,
// DEFAULT, COMMENT, and ON UPDATE clauses.
func (p *MySQLParser) parseColumnAttributes(field *gdb.TableField, attrs string) {
words := strings.Fields(attrs)
upperWords := strings.Fields(strings.ToUpper(attrs))
for i := 0; i < len(upperWords); i++ {
switch upperWords[i] {
case "NOT":
if i+1 < len(upperWords) && upperWords[i+1] == "NULL" {
field.Null = false
i++
}
case "NULL":
field.Null = true
case "PRIMARY":
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
field.Key = "PRI"
i++
}
case "UNIQUE":
if field.Key == "" {
field.Key = "UNI"
}
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
i++
}
case "KEY":
if field.Key == "" {
field.Key = "MUL"
}
case "AUTO_INCREMENT":
field.Extra = "auto_increment"
case "DEFAULT":
if i+1 < len(words) {
defaultVal, _ := extractDefaultValue("DEFAULT " + strings.Join(words[i+1:], " "))
field.Default = defaultVal
if defaultVal != nil {
if strings.HasPrefix(words[i+1], "'") {
for j := i + 1; j < len(words); j++ {
if strings.HasSuffix(words[j], "'") {
i = j
break
}
}
} else {
i++
}
}
}
case "COMMENT":
if i+1 < len(words) {
comment := strings.Join(words[i+1:], " ")
comment = strings.TrimSpace(comment)
if len(comment) >= 2 && comment[0] == '\'' && comment[len(comment)-1] == '\'' {
comment = comment[1 : len(comment)-1]
comment = strings.ReplaceAll(comment, "''", "'")
}
field.Comment = comment
return
}
case "ON":
if i+1 < len(upperWords) && upperWords[i+1] == "UPDATE" {
if i+2 < len(upperWords) {
if field.Extra != "" {
field.Extra += ", "
}
field.Extra += "on update " + strings.ToLower(words[i+2])
i += 2
}
}
}
}
}

View File

@ -0,0 +1,300 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_MySQL_CreateTable_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'User ID',
name VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'User name',
email VARCHAR(200) NULL COMMENT 'Email address',
age INT(11) DEFAULT 0,
score DECIMAL(10,2) DEFAULT 0.00,
status TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='User table';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 1)
fields := tables["users"]
t.Assert(len(fields), 8)
// Check id field
t.Assert(fields["id"].Name, "id")
t.Assert(fields["id"].Type, "BIGINT(20) unsigned")
t.Assert(fields["id"].Null, false)
t.Assert(fields["id"].Key, "PRI")
t.Assert(fields["id"].Extra, "auto_increment")
t.Assert(fields["id"].Comment, "User ID")
t.Assert(fields["id"].Index, 0)
// Check name field
t.Assert(fields["name"].Name, "name")
t.Assert(fields["name"].Null, false)
t.Assert(fields["name"].Comment, "User name")
// Check email field
t.Assert(fields["email"].Null, true)
// Check created_at
t.Assert(fields["created_at"].Null, false)
})
}
func Test_MySQL_AlterTable_AddColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
);
ALTER TABLE users ADD COLUMN email VARCHAR(200) NULL COMMENT 'Email';
ALTER TABLE users ADD COLUMN age INT DEFAULT 0;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 4)
t.Assert(fields["email"].Name, "email")
t.Assert(fields["email"].Null, true)
t.Assert(fields["email"].Comment, "Email")
t.Assert(fields["age"].Name, "age")
})
}
func Test_MySQL_AlterTable_DropColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100),
old_field VARCHAR(50),
PRIMARY KEY (id)
);
ALTER TABLE users DROP COLUMN old_field;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 2)
_, ok := fields["old_field"]
t.Assert(ok, false)
})
}
func Test_MySQL_AlterTable_ModifyColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100),
PRIMARY KEY (id)
);
ALTER TABLE users MODIFY COLUMN name VARCHAR(200) NOT NULL COMMENT 'Full name';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(fields["name"].Type, "VARCHAR(200)")
t.Assert(fields["name"].Null, false)
t.Assert(fields["name"].Comment, "Full name")
})
}
func Test_MySQL_AlterTable_ChangeColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
old_name VARCHAR(100),
PRIMARY KEY (id)
);
ALTER TABLE users CHANGE COLUMN old_name new_name VARCHAR(200) NOT NULL;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
_, ok := fields["old_name"]
t.Assert(ok, false)
t.Assert(fields["new_name"].Name, "new_name")
t.Assert(fields["new_name"].Type, "VARCHAR(200)")
})
}
func Test_MySQL_AlterTable_AddPrimaryKey(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (
id INT NOT NULL,
name VARCHAR(100)
);
ALTER TABLE users ADD PRIMARY KEY (id);
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(tables["users"]["id"].Key, "PRI")
})
}
func Test_MySQL_DropTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE temp_log (id INT, msg TEXT);
CREATE TABLE users (id INT, name VARCHAR(100));
DROP TABLE IF EXISTS temp_log;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 1)
_, ok := tables["temp_log"]
t.Assert(ok, false)
_, ok = tables["users"]
t.Assert(ok, true)
})
}
func Test_MySQL_MultipleMigrations(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
// Simulate V1: initial schema
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, `
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
PRIMARY KEY (id)
);
`, tables)
t.AssertNil(err)
// Simulate V2: add columns
err = processSQL(parser, `
ALTER TABLE users ADD COLUMN email VARCHAR(200) NULL;
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL;
`, tables)
t.AssertNil(err)
// Simulate V3: modify + drop
err = processSQL(parser, `
ALTER TABLE users MODIFY COLUMN name VARCHAR(100) NOT NULL COMMENT 'Full name';
ALTER TABLE users DROP COLUMN phone;
`, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 3) // id, name, email
t.Assert(fields["name"].Type, "VARCHAR(100)")
t.Assert(fields["name"].Comment, "Full name")
_, ok := fields["phone"]
t.Assert(ok, false)
t.Assert(fields["email"].Null, true)
})
}
func Test_MySQL_FullMigrationScenario(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
tables := make(map[string]map[string]*gdb.TableField)
// V001: Initial tables
err := processSQL(parser, `
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Primary key',
username VARCHAR(50) NOT NULL COMMENT 'Username',
password VARCHAR(128) NOT NULL COMMENT 'Hashed password',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_username (username)
);
CREATE TABLE IF NOT EXISTS orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
PRIMARY KEY (id)
);
`, tables)
t.AssertNil(err)
t.Assert(len(tables), 2)
// V002: Add email, phone
err = processSQL(parser, `
ALTER TABLE users ADD COLUMN email VARCHAR(200) NULL COMMENT 'User email';
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL COMMENT 'Phone number';
`, tables)
t.AssertNil(err)
t.Assert(len(tables["users"]), 6)
// V003: Modify, rename, drop
err = processSQL(parser, `
ALTER TABLE users MODIFY COLUMN username VARCHAR(100) NOT NULL COMMENT 'Login name';
ALTER TABLE users CHANGE COLUMN phone mobile VARCHAR(20) NULL COMMENT 'Mobile number';
ALTER TABLE users DROP COLUMN password;
ALTER TABLE orders ADD COLUMN status TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Order status';
`, tables)
t.AssertNil(err)
userFields := tables["users"]
t.Assert(len(userFields), 5) // id, username, email, mobile, created_at
t.Assert(userFields["username"].Type, "VARCHAR(100)")
t.Assert(userFields["username"].Comment, "Login name")
_, ok := userFields["password"]
t.Assert(ok, false)
_, ok = userFields["phone"]
t.Assert(ok, false)
t.Assert(userFields["mobile"].Name, "mobile")
t.Assert(userFields["mobile"].Comment, "Mobile number")
orderFields := tables["orders"]
t.Assert(len(orderFields), 4)
t.Assert(orderFields["status"].Default, "0")
// V004: Drop table
err = processSQL(parser, `
DROP TABLE IF EXISTS orders;
`, tables)
t.AssertNil(err)
t.Assert(len(tables), 1)
_, ok = tables["orders"]
t.Assert(ok, false)
})
}

View File

@ -0,0 +1,209 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
)
// OracleParser implements SQLParser for Oracle/DM DDL.
type OracleParser struct{}
// ParseCreateTable parses a single Oracle CREATE TABLE statement.
func (p *OracleParser) ParseCreateTable(stmt string) (string, map[string]*gdb.TableField, error) {
body, _, ok := extractBodyAndTrailing(stmt)
if !ok {
return "", nil, nil
}
parenIdx := strings.Index(stmt, "(")
header := stmt[:parenIdx]
tableName := extractTableName(header)
if tableName == "" {
return "", nil, fmt.Errorf("cannot extract table name from: %s", header)
}
columnDefs := splitColumns(body)
fields := make(map[string]*gdb.TableField)
pkColumns := findPrimaryKeysFromConstraints(columnDefs)
fieldIndex := 0
for _, def := range columnDefs {
def = strings.TrimSpace(def)
if def == "" {
continue
}
firstWord := strings.ToUpper(strings.Fields(def)[0])
if isConstraintKeyword(firstWord) {
continue
}
field, err := p.parseColumnDef(def, fieldIndex)
if err != nil {
continue
}
if field != nil {
fields[field.Name] = field
fieldIndex++
}
}
for _, pkCol := range pkColumns {
if f, ok := fields[pkCol]; ok {
f.Key = "PRI"
}
upperPk := strings.ToUpper(pkCol)
if f, ok := fields[upperPk]; ok {
f.Key = "PRI"
}
}
return tableName, fields, nil
}
// ParseAlterTable parses Oracle ALTER TABLE statements.
func (p *OracleParser) ParseAlterTable(stmt string, tables map[string]map[string]*gdb.TableField) error {
return parseAlterTableCommon(stmt, tables, p.parseColumnDef)
}
// ParseComment parses COMMENT ON COLUMN table.column IS 'comment'.
func (p *OracleParser) ParseComment(stmt string, tables map[string]map[string]*gdb.TableField) {
upper := strings.ToUpper(strings.TrimSpace(stmt))
if !strings.HasPrefix(upper, "COMMENT ON COLUMN") {
return
}
rest := strings.TrimSpace(stmt[len("COMMENT ON COLUMN"):])
isIdx := strings.Index(strings.ToUpper(rest), " IS ")
if isIdx < 0 {
return
}
ref := strings.TrimSpace(rest[:isIdx])
comment := strings.TrimSpace(rest[isIdx+4:])
if len(comment) >= 2 && comment[0] == '\'' && comment[len(comment)-1] == '\'' {
comment = comment[1 : len(comment)-1]
comment = strings.ReplaceAll(comment, "''", "'")
}
parts := strings.Split(ref, ".")
var tableName, columnName string
switch len(parts) {
case 2:
tableName = unquoteIdentifier(parts[0])
columnName = unquoteIdentifier(parts[1])
case 3:
tableName = unquoteIdentifier(parts[1])
columnName = unquoteIdentifier(parts[2])
default:
return
}
if fields, ok := tables[tableName]; ok {
if field, ok := fields[columnName]; ok {
field.Comment = comment
}
}
}
// parseColumnDef parses a single Oracle column definition string into a TableField.
// It handles Oracle-specific types including TIMESTAMP WITH TIME ZONE and
// TIMESTAMP WITH LOCAL TIME ZONE.
func (p *OracleParser) parseColumnDef(def string, index int) (*gdb.TableField, error) {
tokens := mysqlTokenize(def)
if len(tokens) < 2 {
return nil, fmt.Errorf("invalid column definition: %s", def)
}
field := &gdb.TableField{
Index: index,
Name: unquoteIdentifier(tokens[0]),
Null: true,
}
field.Type = tokens[1]
rest := ""
if len(tokens) > 2 {
rest = strings.Join(tokens[2:], " ")
}
if !strings.Contains(field.Type, "(") && strings.HasPrefix(strings.TrimSpace(rest), "(") {
end := strings.Index(rest, ")")
if end >= 0 {
field.Type += rest[:end+1]
rest = strings.TrimSpace(rest[end+1:])
}
}
// Handle TIMESTAMP WITH TIME ZONE / WITH LOCAL TIME ZONE
upperType := strings.ToUpper(field.Type)
upperRest := strings.ToUpper(rest)
if upperType == "TIMESTAMP" {
if strings.HasPrefix(upperRest, "WITH LOCAL TIME ZONE") {
field.Type = "timestamp with local time zone"
rest = strings.TrimSpace(rest[len("WITH LOCAL TIME ZONE"):])
} else if strings.HasPrefix(upperRest, "WITH TIME ZONE") {
field.Type = "timestamp with time zone"
rest = strings.TrimSpace(rest[len("WITH TIME ZONE"):])
}
}
p.parseColumnAttributes(field, rest)
return field, nil
}
// parseColumnAttributes parses Oracle column constraint keywords including
// NOT NULL, NULL, PRIMARY KEY, UNIQUE, DEFAULT, and GENERATED ... AS IDENTITY.
func (p *OracleParser) parseColumnAttributes(field *gdb.TableField, attrs string) {
words := strings.Fields(attrs)
upperWords := strings.Fields(strings.ToUpper(attrs))
for i := 0; i < len(upperWords); i++ {
switch upperWords[i] {
case "NOT":
if i+1 < len(upperWords) && upperWords[i+1] == "NULL" {
field.Null = false
i++
}
case "NULL":
field.Null = true
case "PRIMARY":
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
field.Key = "PRI"
i++
}
case "UNIQUE":
if field.Key == "" {
field.Key = "UNI"
}
case "DEFAULT":
if i+1 < len(words) {
defaultVal, _ := extractDefaultValue("DEFAULT " + strings.Join(words[i+1:], " "))
field.Default = defaultVal
if defaultVal != nil {
i++
}
}
case "GENERATED":
rest := strings.Join(upperWords[i:], " ")
if strings.Contains(rest, "AS IDENTITY") {
field.Extra = "auto_increment"
for j := i + 1; j < len(upperWords); j++ {
if upperWords[j] == "IDENTITY" {
i = j
break
}
}
}
}
}
}

View File

@ -0,0 +1,97 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_Oracle_CreateTable_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &OracleParser{}
sql := `
CREATE TABLE users (
ID NUMBER(10) NOT NULL,
NAME VARCHAR2(100) NOT NULL,
EMAIL VARCHAR2(200),
CREATED_AT TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
CONSTRAINT PK_USERS PRIMARY KEY (ID)
);
COMMENT ON COLUMN users.ID IS 'User ID';
COMMENT ON COLUMN users.NAME IS 'User name';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 4)
t.Assert(fields["ID"].Key, "PRI")
t.Assert(fields["ID"].Null, false)
t.Assert(fields["ID"].Comment, "User ID")
t.Assert(fields["NAME"].Null, false)
t.Assert(fields["NAME"].Comment, "User name")
t.Assert(fields["CREATED_AT"].Type, "timestamp with time zone")
})
}
func Test_Oracle_AlterTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &OracleParser{}
sql := `
CREATE TABLE users (
ID NUMBER(10) NOT NULL,
NAME VARCHAR2(100),
CONSTRAINT PK_USERS PRIMARY KEY (ID)
);
ALTER TABLE users ADD EMAIL VARCHAR2(200);
ALTER TABLE users MODIFY NAME VARCHAR2(200) NOT NULL;
COMMENT ON COLUMN users.EMAIL IS 'Email address';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 3)
t.Assert(fields["EMAIL"].Comment, "Email address")
t.Assert(fields["NAME"].Type, "VARCHAR2(200)")
t.Assert(fields["NAME"].Null, false)
})
}
func Test_Oracle_AlterTable_DropColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &OracleParser{}
sql := `
CREATE TABLE users (
ID NUMBER(10) NOT NULL,
NAME VARCHAR2(100) NOT NULL,
OLD_COL VARCHAR2(50),
EMAIL VARCHAR2(200),
CONSTRAINT PK_USERS PRIMARY KEY (ID)
);
ALTER TABLE users DROP COLUMN OLD_COL;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 3)
_, ok := fields["OLD_COL"]
t.Assert(ok, false)
t.Assert(fields["NAME"].Name, "NAME")
t.Assert(fields["EMAIL"].Name, "EMAIL")
})
}

View File

@ -0,0 +1,268 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
)
// PgSQLParser implements SQLParser for PostgreSQL DDL.
type PgSQLParser struct{}
// ParseCreateTable parses a single PostgreSQL CREATE TABLE statement.
func (p *PgSQLParser) ParseCreateTable(stmt string) (string, map[string]*gdb.TableField, error) {
body, _, ok := extractBodyAndTrailing(stmt)
if !ok {
return "", nil, nil
}
parenIdx := strings.Index(stmt, "(")
header := stmt[:parenIdx]
tableName := extractTableName(header)
if tableName == "" {
return "", nil, fmt.Errorf("cannot extract table name from: %s", header)
}
columnDefs := splitColumns(body)
fields := make(map[string]*gdb.TableField)
pkColumns := findPrimaryKeysFromConstraints(columnDefs)
fieldIndex := 0
for _, def := range columnDefs {
def = strings.TrimSpace(def)
if def == "" {
continue
}
firstWord := strings.ToUpper(strings.Fields(def)[0])
if isConstraintKeyword(firstWord) {
continue
}
field, err := p.parseColumnDef(def, fieldIndex)
if err != nil {
continue
}
if field != nil {
fields[field.Name] = field
fieldIndex++
}
}
for _, pkCol := range pkColumns {
if f, ok := fields[pkCol]; ok {
f.Key = "PRI"
}
}
return tableName, fields, nil
}
// ParseAlterTable parses PostgreSQL ALTER TABLE statements.
func (p *PgSQLParser) ParseAlterTable(stmt string, tables map[string]map[string]*gdb.TableField) error {
return parseAlterTableCommon(stmt, tables, p.parseColumnDef)
}
// ParseComment parses COMMENT ON COLUMN schema.table.column IS 'comment' statements.
func (p *PgSQLParser) ParseComment(stmt string, tables map[string]map[string]*gdb.TableField) {
upper := strings.ToUpper(strings.TrimSpace(stmt))
if !strings.HasPrefix(upper, "COMMENT ON COLUMN") {
return
}
rest := strings.TrimSpace(stmt[len("COMMENT ON COLUMN"):])
isIdx := strings.Index(strings.ToUpper(rest), " IS ")
if isIdx < 0 {
return
}
ref := strings.TrimSpace(rest[:isIdx])
comment := strings.TrimSpace(rest[isIdx+4:])
if len(comment) >= 2 && comment[0] == '\'' && comment[len(comment)-1] == '\'' {
comment = comment[1 : len(comment)-1]
comment = strings.ReplaceAll(comment, "''", "'")
}
parts := strings.Split(ref, ".")
var tableName, columnName string
switch len(parts) {
case 2:
tableName = unquoteIdentifier(parts[0])
columnName = unquoteIdentifier(parts[1])
case 3:
tableName = unquoteIdentifier(parts[1])
columnName = unquoteIdentifier(parts[2])
default:
return
}
if fields, ok := tables[tableName]; ok {
if field, ok := fields[columnName]; ok {
field.Comment = comment
}
}
}
// parseColumnDef parses a single PostgreSQL column definition string into a TableField.
// It handles PostgreSQL-specific types like SERIAL/BIGSERIAL (auto-increment shorthand),
// CHARACTER VARYING, DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, and array types.
func (p *PgSQLParser) parseColumnDef(def string, index int) (*gdb.TableField, error) {
tokens := mysqlTokenize(def)
if len(tokens) < 2 {
return nil, fmt.Errorf("invalid column definition: %s", def)
}
field := &gdb.TableField{
Index: index,
Name: unquoteIdentifier(tokens[0]),
Null: true,
}
// Handle SERIAL types
typeToken := strings.ToUpper(tokens[1])
switch typeToken {
case "SERIAL":
field.Type = "int"
field.Extra = "auto_increment"
field.Null = false
case "BIGSERIAL":
field.Type = "bigint"
field.Extra = "auto_increment"
field.Null = false
case "SMALLSERIAL":
field.Type = "smallint"
field.Extra = "auto_increment"
field.Null = false
default:
field.Type = tokens[1]
}
rest := ""
if len(tokens) > 2 {
rest = strings.Join(tokens[2:], " ")
}
upperType := strings.ToUpper(field.Type)
upperRest := strings.ToUpper(rest)
switch {
case upperType == "CHARACTER" && strings.HasPrefix(upperRest, "VARYING"):
rest = strings.TrimSpace(rest[len("VARYING"):])
if strings.HasPrefix(rest, "(") {
end := strings.Index(rest, ")")
if end >= 0 {
field.Type = "character varying" + rest[:end+1]
rest = strings.TrimSpace(rest[end+1:])
}
} else {
field.Type = "character varying"
}
case upperType == "DOUBLE" && strings.HasPrefix(upperRest, "PRECISION"):
field.Type = "double precision"
rest = strings.TrimSpace(rest[len("PRECISION"):])
case (upperType == "TIMESTAMP" || upperType == "TIME") &&
(strings.HasPrefix(upperRest, "WITH TIME ZONE") || strings.HasPrefix(upperRest, "WITHOUT TIME ZONE")):
if strings.HasPrefix(upperRest, "WITH TIME ZONE") {
if upperType == "TIMESTAMP" {
field.Type = "timestamptz"
} else {
field.Type = "time with time zone"
}
rest = strings.TrimSpace(rest[len("WITH TIME ZONE"):])
} else {
field.Type = strings.ToLower(upperType)
rest = strings.TrimSpace(rest[len("WITHOUT TIME ZONE"):])
}
case !strings.Contains(field.Type, "(") && strings.HasPrefix(strings.TrimSpace(rest), "("):
end := strings.Index(rest, ")")
if end >= 0 {
field.Type += rest[:end+1]
rest = strings.TrimSpace(rest[end+1:])
}
}
// Handle array types
if strings.HasPrefix(rest, "[]") {
field.Type += "[]"
rest = strings.TrimSpace(rest[2:])
} else if strings.HasPrefix(strings.ToUpper(rest), "ARRAY") {
field.Type += "[]"
rest = strings.TrimSpace(rest[5:])
}
p.parseColumnAttributes(field, rest)
return field, nil
}
// parseColumnAttributes parses PostgreSQL column constraint keywords including
// NOT NULL, NULL, PRIMARY KEY, UNIQUE, DEFAULT, GENERATED ... AS IDENTITY, and REFERENCES.
func (p *PgSQLParser) parseColumnAttributes(field *gdb.TableField, attrs string) {
words := strings.Fields(attrs)
upperWords := strings.Fields(strings.ToUpper(attrs))
for i := 0; i < len(upperWords); i++ {
switch upperWords[i] {
case "NOT":
if i+1 < len(upperWords) && upperWords[i+1] == "NULL" {
field.Null = false
i++
}
case "NULL":
field.Null = true
case "PRIMARY":
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
field.Key = "PRI"
i++
}
case "UNIQUE":
if field.Key == "" {
field.Key = "UNI"
}
case "DEFAULT":
if i+1 < len(words) {
defaultVal, _ := extractDefaultValue("DEFAULT " + strings.Join(words[i+1:], " "))
field.Default = defaultVal
if defaultVal != nil {
i++
}
}
case "GENERATED":
if containsSequence(upperWords[i:], "ALWAYS", "AS", "IDENTITY") ||
containsSequence(upperWords[i:], "BY", "DEFAULT", "AS", "IDENTITY") {
field.Extra = "auto_increment"
for j := i + 1; j < len(upperWords); j++ {
if upperWords[j] == "IDENTITY" {
i = j
break
}
}
}
case "REFERENCES":
for j := i + 1; j < len(upperWords); j++ {
i = j
if strings.Contains(words[j], ")") {
break
}
}
}
}
}
// containsSequence checks if words slice contains the given word sequence starting from index 1.
func containsSequence(words []string, seq ...string) bool {
if len(words) < len(seq)+1 {
return false
}
for i, s := range seq {
if words[i+1] != s {
return false
}
}
return true
}

View File

@ -0,0 +1,232 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_PgSQL_CreateTable_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email CHARACTER VARYING(200),
score DOUBLE PRECISION DEFAULT 0.0,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
COMMENT ON COLUMN users.name IS 'User full name';
COMMENT ON COLUMN users.email IS 'Email address';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 7)
// BIGSERIAL should be auto_increment bigint
t.Assert(fields["id"].Type, "bigint")
t.Assert(fields["id"].Extra, "auto_increment")
t.Assert(fields["id"].Key, "PRI")
// CHARACTER VARYING
t.AssertNE(fields["email"], nil)
// DOUBLE PRECISION
t.Assert(fields["score"].Type, "double precision")
// JSONB
t.Assert(fields["metadata"].Type, "JSONB")
// TIMESTAMP WITH TIME ZONE
t.Assert(fields["created_at"].Type, "timestamptz")
// COMMENT ON COLUMN
t.Assert(fields["name"].Comment, "User full name")
t.Assert(fields["email"].Comment, "Email address")
})
}
func Test_PgSQL_AlterTable_AddColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
ALTER TABLE users ADD COLUMN email VARCHAR(200);
COMMENT ON COLUMN users.email IS 'User email';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 3)
t.Assert(fields["email"].Name, "email")
t.Assert(fields["email"].Comment, "User email")
})
}
func Test_PgSQL_AlterTable_AlterColumnType(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100)
);
ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(200);
ALTER TABLE users ALTER COLUMN name SET NOT NULL;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(fields["name"].Type, "VARCHAR(200)")
t.Assert(fields["name"].Null, false)
})
}
func Test_PgSQL_AlterTable_DropColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
old_col TEXT
);
ALTER TABLE users DROP COLUMN old_col;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 2)
_, ok := fields["old_col"]
t.Assert(ok, false)
})
}
func Test_PgSQL_AlterTable_RenameColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
old_name VARCHAR(100)
);
ALTER TABLE users RENAME COLUMN old_name TO new_name;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
_, ok := fields["old_name"]
t.Assert(ok, false)
t.Assert(fields["new_name"].Name, "new_name")
})
}
func Test_PgSQL_MultipleMigrations(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
tables := make(map[string]map[string]*gdb.TableField)
// V1
err := processSQL(parser, `
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price NUMERIC(10,2) DEFAULT 0.00
);
`, tables)
t.AssertNil(err)
// V2: add, alter, comment
err = processSQL(parser, `
ALTER TABLE products ADD COLUMN category VARCHAR(50);
ALTER TABLE products ALTER COLUMN name TYPE VARCHAR(200);
ALTER TABLE products ALTER COLUMN name SET NOT NULL;
COMMENT ON COLUMN products.category IS 'Product category';
`, tables)
t.AssertNil(err)
// V3: rename, drop
err = processSQL(parser, `
ALTER TABLE products RENAME COLUMN category TO product_category;
`, tables)
t.AssertNil(err)
fields := tables["products"]
t.Assert(len(fields), 4)
t.Assert(fields["name"].Type, "VARCHAR(200)")
t.Assert(fields["name"].Null, false)
_, ok := fields["category"]
t.Assert(ok, false)
t.Assert(fields["product_category"].Name, "product_category")
t.Assert(fields["product_category"].Comment, "Product category")
})
}
func Test_PgSQL_FullMigrationScenario(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
tables := make(map[string]map[string]*gdb.TableField)
// V001: Initial
err := processSQL(parser, `
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200) UNIQUE
);
COMMENT ON COLUMN users.name IS 'User name';
`, tables)
t.AssertNil(err)
// V002: Add, alter type, set not null
err = processSQL(parser, `
ALTER TABLE users ADD COLUMN avatar TEXT;
ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(200);
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
COMMENT ON COLUMN users.avatar IS 'Avatar URL';
`, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 4)
t.Assert(fields["name"].Type, "VARCHAR(200)")
t.Assert(fields["email"].Null, false)
t.Assert(fields["avatar"].Comment, "Avatar URL")
// V003: Rename column, drop not null
err = processSQL(parser, `
ALTER TABLE users RENAME COLUMN avatar TO profile_image;
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
`, tables)
t.AssertNil(err)
_, ok := fields["avatar"]
t.Assert(ok, false)
t.Assert(fields["profile_image"].Name, "profile_image")
t.Assert(fields["email"].Null, true)
})
}

View File

@ -0,0 +1,159 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
)
// SQLiteParser implements SQLParser for SQLite DDL.
type SQLiteParser struct{}
// ParseCreateTable parses a single SQLite CREATE TABLE statement.
func (p *SQLiteParser) ParseCreateTable(stmt string) (string, map[string]*gdb.TableField, error) {
body, _, ok := extractBodyAndTrailing(stmt)
if !ok {
return "", nil, nil
}
parenIdx := strings.Index(stmt, "(")
header := stmt[:parenIdx]
tableName := extractTableName(header)
if tableName == "" {
return "", nil, fmt.Errorf("cannot extract table name from: %s", header)
}
columnDefs := splitColumns(body)
fields := make(map[string]*gdb.TableField)
pkColumns := findPrimaryKeysFromConstraints(columnDefs)
fieldIndex := 0
for _, def := range columnDefs {
def = strings.TrimSpace(def)
if def == "" {
continue
}
firstWord := strings.ToUpper(strings.Fields(def)[0])
if isConstraintKeyword(firstWord) {
continue
}
field, err := p.parseColumnDef(def, fieldIndex)
if err != nil {
continue
}
if field != nil {
fields[field.Name] = field
fieldIndex++
}
}
for _, pkCol := range pkColumns {
if f, ok := fields[pkCol]; ok {
f.Key = "PRI"
}
}
return tableName, fields, nil
}
// ParseAlterTable parses SQLite ALTER TABLE statements.
// Note: SQLite only supports ADD COLUMN and RENAME COLUMN in ALTER TABLE.
func (p *SQLiteParser) ParseAlterTable(stmt string, tables map[string]map[string]*gdb.TableField) error {
return parseAlterTableCommon(stmt, tables, p.parseColumnDef)
}
// ParseComment is a no-op for SQLite as it doesn't support COMMENT ON statements.
func (p *SQLiteParser) ParseComment(stmt string, tables map[string]map[string]*gdb.TableField) {
// SQLite does not support comments on columns.
}
// parseColumnDef parses a single SQLite column definition string into a TableField.
// SQLite has flexible typing (type affinity), so columns may have no explicit type,
// in which case "text" is used as the default type.
func (p *SQLiteParser) parseColumnDef(def string, index int) (*gdb.TableField, error) {
tokens := mysqlTokenize(def)
if len(tokens) < 1 {
return nil, fmt.Errorf("invalid column definition: %s", def)
}
field := &gdb.TableField{
Index: index,
Name: unquoteIdentifier(tokens[0]),
Null: true,
}
if len(tokens) < 2 {
field.Type = "text"
return field, nil
}
field.Type = tokens[1]
rest := ""
if len(tokens) > 2 {
rest = strings.Join(tokens[2:], " ")
}
if !strings.Contains(field.Type, "(") && strings.HasPrefix(strings.TrimSpace(rest), "(") {
end := strings.Index(rest, ")")
if end >= 0 {
field.Type += rest[:end+1]
rest = strings.TrimSpace(rest[end+1:])
}
}
p.parseColumnAttributes(field, rest)
return field, nil
}
// parseColumnAttributes parses SQLite column constraint keywords including
// NOT NULL, NULL, PRIMARY KEY (with optional AUTOINCREMENT), UNIQUE, and DEFAULT.
func (p *SQLiteParser) parseColumnAttributes(field *gdb.TableField, attrs string) {
words := strings.Fields(attrs)
upperWords := strings.Fields(strings.ToUpper(attrs))
for i := 0; i < len(upperWords); i++ {
switch upperWords[i] {
case "NOT":
if i+1 < len(upperWords) && upperWords[i+1] == "NULL" {
field.Null = false
i++
}
case "NULL":
field.Null = true
case "PRIMARY":
if i+1 < len(upperWords) && upperWords[i+1] == "KEY" {
field.Key = "PRI"
field.Null = false
i++
if i+1 < len(upperWords) && upperWords[i+1] == "AUTOINCREMENT" {
field.Extra = "auto_increment"
i++
}
}
case "AUTOINCREMENT":
field.Extra = "auto_increment"
case "UNIQUE":
if field.Key == "" {
field.Key = "UNI"
}
case "DEFAULT":
if i+1 < len(words) {
defaultVal, _ := extractDefaultValue("DEFAULT " + strings.Join(words[i+1:], " "))
field.Default = defaultVal
if defaultVal != nil {
i++
}
}
}
}
}

View File

@ -0,0 +1,112 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_SQLite_CreateTable_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &SQLiteParser{}
sql := `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
age INTEGER DEFAULT 0,
score REAL DEFAULT 0.0,
is_active BOOLEAN NOT NULL DEFAULT 1
);
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 6)
t.Assert(fields["id"].Key, "PRI")
t.Assert(fields["id"].Extra, "auto_increment")
t.Assert(fields["id"].Null, false)
t.Assert(fields["name"].Null, false)
t.Assert(fields["email"].Null, true)
t.Assert(fields["age"].Default, "0")
})
}
func Test_SQLite_AlterTable_AddColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &SQLiteParser{}
sql := `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
ALTER TABLE users ADD COLUMN email TEXT;
ALTER TABLE users ADD COLUMN phone TEXT DEFAULT '';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 4)
t.Assert(fields["email"].Name, "email")
t.Assert(fields["phone"].Name, "phone")
})
}
func Test_SQLite_AlterTable_DropColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &SQLiteParser{}
sql := `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
old_col TEXT,
email TEXT
);
ALTER TABLE users DROP COLUMN old_col;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
t.Assert(len(fields), 3)
_, ok := fields["old_col"]
t.Assert(ok, false)
t.Assert(fields["name"].Name, "name")
t.Assert(fields["email"].Name, "email")
})
}
func Test_SQLite_RenameColumn(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &SQLiteParser{}
sql := `
CREATE TABLE users (
id INTEGER PRIMARY KEY,
old_name TEXT NOT NULL
);
ALTER TABLE users RENAME COLUMN old_name TO new_name;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
fields := tables["users"]
_, ok := fields["old_name"]
t.Assert(ok, false)
t.Assert(fields["new_name"].Name, "new_name")
})
}

View File

@ -0,0 +1,302 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gendao
import (
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
// ===========================
// Common parser utilities tests
// ===========================
func Test_splitSQLStatements(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
stmts := splitSQLStatements("CREATE TABLE t1 (id INT); ALTER TABLE t1 ADD COLUMN name VARCHAR(100);")
t.Assert(len(stmts), 2)
t.AssertIN("CREATE TABLE t1 (id INT)", stmts)
})
}
func Test_splitSQLStatements_WithComments(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
sql := `
-- This is a comment
CREATE TABLE t1 (id INT);
/* Block comment */
ALTER TABLE t1 ADD COLUMN name VARCHAR(100);
`
stmts := splitSQLStatements(sql)
t.Assert(len(stmts), 2)
})
}
func Test_splitSQLStatements_WithQuotedSemicolon(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
sql := `CREATE TABLE t1 (id INT, name VARCHAR(100) DEFAULT 'a;b');`
stmts := splitSQLStatements(sql)
t.Assert(len(stmts), 1)
})
}
func Test_classifyStatement(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
t.Assert(classifyStatement("CREATE TABLE users (id INT)"), SQLStatementCreateTable)
t.Assert(classifyStatement("CREATE TEMPORARY TABLE tmp (id INT)"), SQLStatementCreateTable)
t.Assert(classifyStatement("ALTER TABLE users ADD COLUMN email VARCHAR(100)"), SQLStatementAlterTable)
t.Assert(classifyStatement("ALTER TABLE users RENAME TO customers"), SQLStatementRenameTable)
t.Assert(classifyStatement("DROP TABLE IF EXISTS users"), SQLStatementDropTable)
t.Assert(classifyStatement("RENAME TABLE old_name TO new_name"), SQLStatementRenameTable)
t.Assert(classifyStatement("COMMENT ON COLUMN users.name IS 'User name'"), SQLStatementComment)
t.Assert(classifyStatement("SELECT * FROM users"), SQLStatementUnknown)
t.Assert(classifyStatement("INSERT INTO users VALUES (1)"), SQLStatementUnknown)
})
}
func Test_unquoteIdentifier(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
t.Assert(unquoteIdentifier("`users`"), "users")
t.Assert(unquoteIdentifier(`"users"`), "users")
t.Assert(unquoteIdentifier("[users]"), "users")
t.Assert(unquoteIdentifier("users"), "users")
})
}
func Test_extractTableName(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
t.Assert(extractTableName("CREATE TABLE users"), "users")
t.Assert(extractTableName("CREATE TABLE IF NOT EXISTS users"), "users")
t.Assert(extractTableName("CREATE TABLE `users`"), "users")
t.Assert(extractTableName("CREATE TABLE mydb.users"), "users")
t.Assert(extractTableName("CREATE TEMPORARY TABLE temp_users"), "temp_users")
})
}
func Test_applyDropTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
tables := map[string]map[string]*gdb.TableField{
"users": {},
"logs": {},
}
applyDropTable("DROP TABLE IF EXISTS users", tables)
t.Assert(len(tables), 1)
_, ok := tables["users"]
t.Assert(ok, false)
})
}
func Test_applyRenameTable_MySQL(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
tables := map[string]map[string]*gdb.TableField{
"old_name": {"id": {Index: 0, Name: "id", Type: "int"}},
}
applyRenameTable("RENAME TABLE old_name TO new_name", tables)
t.Assert(len(tables), 1)
_, ok := tables["new_name"]
t.Assert(ok, true)
})
}
func Test_applyRenameTable_PgSQL(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
tables := map[string]map[string]*gdb.TableField{
"old_name": {"id": {Index: 0, Name: "id", Type: "int"}},
}
applyRenameTable("ALTER TABLE old_name RENAME TO new_name", tables)
t.Assert(len(tables), 1)
_, ok := tables["new_name"]
t.Assert(ok, true)
})
}
// ===========================
// Abnormal/edge-case parsing tests
// ===========================
func Test_processSQL_OnlyDMLStatements(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
INSERT INTO users (id, name) VALUES (1, 'Alice');
INSERT INTO users (id, name) VALUES (2, 'Bob');
DELETE FROM users WHERE id = 1;
UPDATE users SET name = 'Charlie' WHERE id = 2;
SELECT * FROM users;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_EmptySQL(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
tables := make(map[string]map[string]*gdb.TableField)
// Empty string
err := processSQL(parser, "", tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
// Only whitespace and newlines
err = processSQL(parser, " \n\n \t ", tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_OnlyComments(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
-- This is a line comment
/* This is a block comment */
-- Another comment
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_AlterNonExistentTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
ALTER TABLE non_existent ADD COLUMN email VARCHAR(200);
ALTER TABLE non_existent DROP COLUMN name;
ALTER TABLE non_existent MODIFY COLUMN name VARCHAR(200);
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_DropNonExistentTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `DROP TABLE IF EXISTS non_existent;`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_MixedDDLAndDML(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
INSERT INTO logs (msg) VALUES ('starting migration');
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO users (name) VALUES ('Alice');
ALTER TABLE users ADD COLUMN email VARCHAR(200);
UPDATE users SET email = 'alice@example.com' WHERE id = 1;
DELETE FROM logs WHERE msg = 'starting migration';
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
// Only DDL statements should be processed; DML should be skipped.
t.Assert(len(tables), 1)
fields := tables["users"]
t.Assert(len(fields), 3)
t.Assert(fields["id"].Key, "PRI")
t.Assert(fields["email"].Name, "email")
})
}
func Test_processSQL_CommentOnNonExistentTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &PgSQLParser{}
sql := `COMMENT ON COLUMN non_existent.col1 IS 'some comment';`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_RenameNonExistentTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `RENAME TABLE non_existent TO new_name;`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
t.Assert(len(tables), 0)
})
}
func Test_processSQL_DropColumnFromNonExistentTable(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
parser := &MySQLParser{}
sql := `
CREATE TABLE users (id INT, name VARCHAR(100), PRIMARY KEY (id));
ALTER TABLE orders DROP COLUMN status;
`
tables := make(map[string]map[string]*gdb.TableField)
err := processSQL(parser, sql, tables)
t.AssertNil(err)
// users table should still exist, orders ALTER should be silently ignored.
t.Assert(len(tables), 1)
t.Assert(len(tables["users"]), 2)
})
}
// ===========================
// CheckLocalTypeForFieldType Tests
// ===========================
func Test_CheckLocalTypeForFieldType(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
tests := []struct {
fieldType string
expected string
}{
{"int(10)", "int"},
{"int(10) unsigned", "uint"},
{"bigint(20)", "int64"},
{"bigint(20) unsigned", "uint64"},
{"tinyint(1)", "int"},
{"varchar(100)", "string"},
{"text", "string"},
{"datetime", "datetime"},
{"timestamp", "datetime"},
{"timestamptz", "datetime"},
{"date", "date"},
{"time", "time"},
{"json", "json"},
{"jsonb", "jsonb"},
{"float", "float64"},
{"double", "float64"},
{"decimal(10,2)", "string"},
{"bool", "bool"},
{"boolean", "bool"},
{"blob", "[]byte"},
{"binary(16)", "[]byte"},
{"bit(1)", "bool"},
}
for _, tt := range tests {
localType, err := gdb.CheckLocalTypeForFieldType(tt.fieldType)
t.AssertNil(err)
t.Assert(string(localType), tt.expected)
}
})
}

View File

@ -20,14 +20,20 @@ import (
"github.com/gogf/gf/v2/text/gstr"
)
// generateStructDefinitionInput holds parameters for generating a Go struct definition
// from database table fields.
type generateStructDefinitionInput struct {
CGenDaoInternalInput
TableName string // Table name.
StructName string // Struct name.
FieldMap map[string]*gdb.TableField // Table field map.
IsDo bool // Is generating DTO struct.
TableName string // Original database table name.
StructName string // Go struct name (CamelCase of table name).
FieldMap map[string]*gdb.TableField // Map of column name to field metadata.
IsDo bool // Whether generating a DO struct (uses g.Meta orm tag).
}
// generateStructDefinition generates a complete Go struct definition string from table fields.
// It returns the struct source code and a list of additional import paths needed
// by custom type mappings. The fields are rendered in a table-aligned format
// using tablewriter for consistent code formatting.
func generateStructDefinition(ctx context.Context, in generateStructDefinitionInput) (string, []string) {
var appendImports []string
buffer := bytes.NewBuffer(nil)
@ -59,6 +65,10 @@ func generateStructDefinition(ctx context.Context, in generateStructDefinitionIn
return buffer.String(), appendImports
}
// getTypeMappingInfo looks up a database field type in the type mapping configuration.
// It handles exact matches first, then tries to extract the base type name from
// parameterized types like "varchar(255)" or "numeric(10,2) unsigned".
// Returns the mapped Go type name and its import path (if any).
func getTypeMappingInfo(
ctx context.Context, fieldType string, inTypeMapping map[DBFieldTypeName]CustomAttributeType,
) (typeNameStr, importStr string) {
@ -105,9 +115,17 @@ func generateStructFieldDefinition(
}
if localTypeNameStr == "" {
localTypeName, err = in.DB.CheckLocalTypeForField(ctx, field.Type, nil)
if err != nil {
panic(err)
if in.DB != nil {
localTypeName, err = in.DB.CheckLocalTypeForField(ctx, field.Type, nil)
if err != nil {
panic(err)
}
} else {
// SQL file mode: use standalone type checking without database connection.
localTypeName, err = gdb.CheckLocalTypeForFieldType(field.Type)
if err != nil {
panic(err)
}
}
localTypeNameStr = string(localTypeName)
switch localTypeName {
@ -181,11 +199,12 @@ func generateStructFieldDefinition(
return attrLines, appendImport
}
// FieldNameCase defines the naming convention for converting field names to Go identifiers.
type FieldNameCase string
const (
FieldNameCaseCamel FieldNameCase = "CaseCamel"
FieldNameCaseCamelLower FieldNameCase = "CaseCamelLower"
FieldNameCaseCamel FieldNameCase = "CaseCamel" // PascalCase: "user_name" -> "UserName"
FieldNameCaseCamelLower FieldNameCase = "CaseCamelLower" // camelCase: "user_name" -> "userName"
)
// formatFieldName formats and returns a new field name that is used for golang codes generating.
@ -208,28 +227,6 @@ func formatFieldName(fieldName string, nameCase FieldNameCase) string {
}
}
// formatFileName formats and returns a new file name for generated source files.
func formatFileName(fileName, nameCase string) string {
if nameCase == "" {
nameCase = string(gstr.Snake)
}
fileName = normalizeNameForCaseConvert(fileName)
fileName = gstr.Trim(gstr.CaseConvert(fileName, gstr.CaseTypeMatch(nameCase)), "-_.")
if len(fileName) > 5 && fileName[len(fileName)-5:] == "_test" {
// Add suffix to avoid the table name which contains "_test",
// which would make the go file a testing file.
fileName += "_table"
}
return fileName
}
func normalizeNameForCaseConvert(name string) string {
if isAllUpper(name) {
return strings.ToLower(name)
}
return name
}
// isAllUpper checks and returns whether given `fieldName` all letters are upper case.
func isAllUpper(fieldName string) bool {
for _, b := range fieldName {

View File

@ -17,6 +17,7 @@ import (
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gview"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/cmd/gf/v2/internal/consts"
@ -61,12 +62,18 @@ type generateTableSingleInput struct {
// generateTableSingle generates dao files for a single table.
func generateTableSingle(ctx context.Context, in generateTableSingleInput) {
// Generating table data preparing.
fieldMap, err := in.DB.TableFields(ctx, in.TableName)
fieldMap, err := getTableFields(ctx, in.CGenDaoInternalInput, in.TableName)
if err != nil {
mlog.Fatalf(`fetching tables fields failed for table "%s": %+v`, in.TableName, err)
}
fileName := formatFileName(in.NewTableName, in.FileNameCase)
tableNameSnakeCase := gstr.CaseSnake(in.NewTableName)
fileName := gstr.Trim(tableNameSnakeCase, "-_.")
if len(fileName) > 5 && fileName[len(fileName)-5:] == "_test" {
// Add suffix to avoid the table name which contains "_test",
// which would make the go file a testing file.
fileName += "_table"
}
path := filepath.FromSlash(gfile.Join(in.DirPathTable, fileName+".go"))
in.genItems.AppendGeneratedFilePath(path)
if in.OverwriteDao || !gfile.Exists(path) {

View File

@ -58,30 +58,25 @@ CONFIGURATION SUPPORT
CGenDaoBriefStdTime = `use time.Time from stdlib instead of gtime.Time for generated time/date fields of tables`
CGenDaoBriefWithTime = `add created time for auto produced go files`
CGenDaoBriefGJsonSupport = `use gJsonSupport to use *gjson.Json instead of string for generated json fields of tables`
CGenDaoBriefFileNameCase = `
generated go file name case for dao/table/do/entity files, cases are as follows:
| Case | Example |
|---------------- |--------------------|
| Snake | any_kind_of_string | default
| SnakeFirstUpper | rgb_code_md5 |
`
CGenDaoBriefImportPrefix = `custom import prefix for generated go files`
CGenDaoBriefDaoPath = `directory path for storing generated dao files under path`
CGenDaoBriefTablePath = `directory path for storing generated table files under path`
CGenDaoBriefDoPath = `directory path for storing generated do files under path`
CGenDaoBriefEntityPath = `directory path for storing generated entity files under path`
CGenDaoBriefOverwriteDao = `overwrite all dao files both inside/outside internal folder`
CGenDaoBriefModelFile = `custom file name for storing generated model content`
CGenDaoBriefModelFileForDao = `custom file name generating model for DAO operations like Where/Data. It's empty in default`
CGenDaoBriefDescriptionTag = `add comment to description tag for each field`
CGenDaoBriefNoJsonTag = `no json tag will be added for each field`
CGenDaoBriefNoModelComment = `no model comment will be added for each field`
CGenDaoBriefClear = `delete all generated go files that do not exist in database`
CGenDaoBriefGenTable = `generate table files`
CGenDaoBriefTypeMapping = `custom local type mapping for generated struct attributes relevant to fields of table`
CGenDaoBriefFieldMapping = `custom local type mapping for generated struct attributes relevant to specific fields of table`
CGenDaoBriefShardingPattern = `sharding pattern for table name, e.g. "users_?" will be replace tables "users_001,users_002,..." to "users" dao`
CGenDaoBriefGroup = `
CGenDaoBriefImportPrefix = `custom import prefix for generated go files`
CGenDaoBriefDaoPath = `directory path for storing generated dao files under path`
CGenDaoBriefTablePath = `directory path for storing generated table files under path`
CGenDaoBriefDoPath = `directory path for storing generated do files under path`
CGenDaoBriefEntityPath = `directory path for storing generated entity files under path`
CGenDaoBriefOverwriteDao = `overwrite all dao files both inside/outside internal folder`
CGenDaoBriefModelFile = `custom file name for storing generated model content`
CGenDaoBriefModelFileForDao = `custom file name generating model for DAO operations like Where/Data. It's empty in default`
CGenDaoBriefDescriptionTag = `add comment to description tag for each field`
CGenDaoBriefNoJsonTag = `no json tag will be added for each field`
CGenDaoBriefNoModelComment = `no model comment will be added for each field`
CGenDaoBriefClear = `delete all generated go files that do not exist in database`
CGenDaoBriefGenTable = `generate table files`
CGenDaoBriefTypeMapping = `custom local type mapping for generated struct attributes relevant to fields of table`
CGenDaoBriefFieldMapping = `custom local type mapping for generated struct attributes relevant to specific fields of table`
CGenDaoBriefShardingPattern = `sharding pattern for table name, e.g. "users_?" will be replace tables "users_001,users_002,..." to "users" dao`
CGenDaoBriefSqlDir = `directory path of SQL DDL files for generating dao/do/entity without database connection`
CGenDaoBriefSqlType = `SQL dialect type when using sqlDir, options: mysql|pgsql|mssql|oracle|sqlite, default is "mysql"`
CGenDaoBriefGroup = `
specifying the configuration group name of database for generated ORM instance,
it's not necessary and the default value is "default"
`
@ -102,21 +97,23 @@ generated json tag case for model struct, cases are as follows:
CGenDaoBriefTplDaoDoPathPath = `template file path for dao do file`
CGenDaoBriefTplDaoEntityPath = `template file path for dao entity file`
tplVarTableName = `TplTableName`
tplVarTableNameCamelCase = `TplTableNameCamelCase`
tplVarTableNameCamelLowerCase = `TplTableNameCamelLowerCase`
tplVarTableSharding = `TplTableSharding`
tplVarTableShardingPrefix = `TplTableShardingPrefix`
tplVarTableFields = `TplTableFields`
tplVarPackageImports = `TplPackageImports`
tplVarImportPrefix = `TplImportPrefix`
tplVarStructDefine = `TplStructDefine`
tplVarColumnDefine = `TplColumnDefine`
tplVarColumnNames = `TplColumnNames`
tplVarGroupName = `TplGroupName`
tplVarDatetimeStr = `TplDatetimeStr`
tplVarCreatedAtDatetimeStr = `TplCreatedAtDatetimeStr`
tplVarPackageName = `TplPackageName`
// Template variable names used by gview for rendering Go file templates.
// These are passed to tplView.Assigns() and referenced in template files.
tplVarTableName = `TplTableName` // Original database table name.
tplVarTableNameCamelCase = `TplTableNameCamelCase` // PascalCase table name (e.g., "UserDetail").
tplVarTableNameCamelLowerCase = `TplTableNameCamelLowerCase` // camelCase table name (e.g., "userDetail").
tplVarTableSharding = `TplTableSharding` // Boolean: whether this is a sharding table.
tplVarTableShardingPrefix = `TplTableShardingPrefix` // Sharding table name prefix (e.g., "user_").
tplVarTableFields = `TplTableFields` // Generated table field definitions.
tplVarPackageImports = `TplPackageImports` // Generated import block string.
tplVarImportPrefix = `TplImportPrefix` // Go import path prefix for internal dao package.
tplVarStructDefine = `TplStructDefine` // Generated struct definition string.
tplVarColumnDefine = `TplColumnDefine` // Column struct field definitions for dao internal.
tplVarColumnNames = `TplColumnNames` // Column name-to-string assignments for dao internal.
tplVarGroupName = `TplGroupName` // Database configuration group name.
tplVarDatetimeStr = `TplDatetimeStr` // Current datetime string for file headers.
tplVarCreatedAtDatetimeStr = `TplCreatedAtDatetimeStr` // "Created at <datetime>" string (empty if WithTime is false).
tplVarPackageName = `TplPackageName` // Go package name for the generated file.
)
func init() {
@ -135,7 +132,6 @@ func init() {
`CGenDaoBriefRemoveFieldPrefix`: CGenDaoBriefRemoveFieldPrefix,
`CGenDaoBriefStdTime`: CGenDaoBriefStdTime,
`CGenDaoBriefWithTime`: CGenDaoBriefWithTime,
`CGenDaoBriefFileNameCase`: CGenDaoBriefFileNameCase,
`CGenDaoBriefDaoPath`: CGenDaoBriefDaoPath,
`CGenDaoBriefTablePath`: CGenDaoBriefTablePath,
`CGenDaoBriefDoPath`: CGenDaoBriefDoPath,
@ -153,6 +149,8 @@ func init() {
`CGenDaoBriefTypeMapping`: CGenDaoBriefTypeMapping,
`CGenDaoBriefFieldMapping`: CGenDaoBriefFieldMapping,
`CGenDaoBriefShardingPattern`: CGenDaoBriefShardingPattern,
`CGenDaoBriefSqlDir`: CGenDaoBriefSqlDir,
`CGenDaoBriefSqlType`: CGenDaoBriefSqlType,
`CGenDaoBriefGroup`: CGenDaoBriefGroup,
`CGenDaoBriefJsonCase`: CGenDaoBriefJsonCase,
`CGenDaoBriefTplDaoIndexPath`: CGenDaoBriefTplDaoIndexPath,

View File

@ -10,7 +10,6 @@ import (
"testing"
"github.com/gogf/gf/v2/test/gtest"
"github.com/gogf/gf/v2/text/gstr"
)
// Test containsWildcard function.
@ -181,12 +180,3 @@ func Test_filterTablesByPatterns_NonExistent(t *testing.T) {
t.AssertNI("nonexistent", result)
})
}
func Test_formatFileName(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
t.Assert(formatFileName("sys_i18n_message", ""), "sys_i_18_n_message")
t.Assert(formatFileName("sys_i18n_message", string(gstr.SnakeFirstUpper)), "sys_i18n_message")
t.Assert(formatFileName("SYS_I18N_MESSAGE", string(gstr.SnakeFirstUpper)), "sys_i18n_message")
t.Assert(formatFileName("user_test", string(gstr.SnakeFirstUpper)), "user_test_table")
})
}

View File

@ -1,137 +0,0 @@
# GoFrame Configuration Field Descriptions - English
# Used by the config visual editor for field descriptions
# Server Module
config.server.name: "Service name for registry and discovery"
config.server.address: "Server listening address like ':port' or 'ip:port', multiple addresses joined with ','"
config.server.httpsAddr: "HTTPS listening address, multiple addresses joined with ','"
config.server.endpoints: "Custom endpoints for service register, uses Address if empty"
config.server.httpsCertPath: "HTTPS certification file path"
config.server.httpsKeyPath: "HTTPS key file path"
config.server.readTimeout: "HTTP read timeout duration for entire request including body"
config.server.writeTimeout: "HTTP write timeout duration for response"
config.server.idleTimeout: "HTTP idle timeout for keep-alive connections"
config.server.maxHeaderBytes: "Maximum number of bytes for parsing request header (default 10KB)"
config.server.keepAlive: "Enable HTTP keep-alive connections"
config.server.serverAgent: "Server agent string in HTTP response header"
config.server.rewrites: "URI rewrite rules map"
config.server.indexFiles: "Index files for static folder"
config.server.indexFolder: "Allow listing sub-files when requesting folder"
config.server.serverRoot: "Root directory for static file service"
config.server.searchPaths: "Additional searching directories for static service"
config.server.fileServerEnabled: "Global switch for static file service"
config.server.cookieMaxAge: "Maximum TTL for cookie items"
config.server.cookiePath: "Cookie path, also affects session id storage"
config.server.cookieDomain: "Cookie domain, also affects session id storage"
config.server.cookieSameSite: "Cookie SameSite property"
config.server.cookieSecure: "Cookie Secure flag"
config.server.cookieHttpOnly: "Cookie HttpOnly flag"
config.server.sessionIdName: "Session ID name in cookie"
config.server.sessionMaxAge: "Maximum TTL for session items"
config.server.sessionPath: "Session file storage directory path"
config.server.sessionCookieMaxAge: "Cookie TTL for session id (0 = expires with browser)"
config.server.sessionCookieOutput: "Automatically output session id to cookie"
config.server.logPath: "Directory path for storing log files"
config.server.logLevel: "Logging level (all, debug, info, notice, warning, error, critical)"
config.server.logStdout: "Output log content to stdout"
config.server.errorStack: "Log stack trace on error"
config.server.errorLogEnabled: "Enable error log to files"
config.server.errorLogPattern: "Error log file name pattern"
config.server.accessLogEnabled: "Enable access log to files"
config.server.accessLogPattern: "Access log file name pattern"
config.server.pprofEnabled: "Enable PProf feature for performance profiling"
config.server.pprofPattern: "PProf service route pattern"
config.server.openapiPath: "OpenApi specification file path"
config.server.swaggerPath: "Swagger UI route path"
config.server.swaggerUITemplate: "Custom Swagger UI HTML template"
config.server.graceful: "Enable graceful reload for all servers"
config.server.gracefulTimeout: "Maximum survival time (seconds) of parent process during graceful reload"
config.server.gracefulShutdownTimeout: "Maximum time (seconds) before stopping server during shutdown"
config.server.clientMaxBodySize: "Maximum client request body size in bytes (default 8MB)"
config.server.formParsingMemory: "Maximum memory buffer for parsing multimedia forms (default 1MB)"
config.server.nameToUriType: "Method name to URI conversion type (0=default, 1=fullname, 2=alllower, 3=camel)"
config.server.routeOverWrite: "Allow overwriting duplicate routes"
config.server.dumpRouterMap: "Dump router map on server start"
# Database Module
config.database.host: "Database server address (IP or domain)"
config.database.port: "Database server port number"
config.database.user: "Authentication username"
config.database.pass: "Authentication password"
config.database.name: "Default database name"
config.database.type: "Database type (mysql, pgsql, sqlite, mssql, oracle, clickhouse, dm)"
config.database.link: "Custom connection string combining all config"
config.database.extra: "Additional options for third-party drivers"
config.database.role: "Node role in master-slave setup (master/slave)"
config.database.debug: "Enable debug mode for logging"
config.database.prefix: "Table name prefix"
config.database.dryRun: "Simulation mode: execute SELECT only, skip INSERT/UPDATE/DELETE"
config.database.weight: "Node weight for load balancing"
config.database.charset: "Character set for database operations"
config.database.protocol: "Network protocol for connection"
config.database.timezone: "Time zone for timestamp interpretation"
config.database.namespace: "Schema namespace (e.g., PostgreSQL schema)"
config.database.maxIdle: "Maximum idle connections in pool"
config.database.maxOpen: "Maximum open connections (0=unlimited)"
config.database.maxLifeTime: "Maximum connection lifetime"
config.database.maxIdleTime: "Maximum connection idle time before close"
config.database.queryTimeout: "DQL (SELECT) query timeout"
config.database.execTimeout: "DML (INSERT/UPDATE/DELETE) execution timeout"
config.database.tranTimeout: "Transaction block timeout"
config.database.prepareTimeout: "Prepare statement timeout"
config.database.createdAt: "Auto timestamp field name for record creation"
config.database.updatedAt: "Auto timestamp field name for record update"
config.database.deletedAt: "Auto timestamp field name for soft delete"
config.database.timeMaintainDisabled: "Disable automatic time maintenance"
# Redis Module
config.redis.address: "Redis server address, multiple addresses joined with ',' for cluster"
config.redis.db: "Redis database index (0-15)"
config.redis.user: "Username for AUTH (Redis 6.0+)"
config.redis.pass: "Password for AUTH"
config.redis.sentinelUser: "Username for Sentinel AUTH"
config.redis.sentinelPass: "Password for Sentinel AUTH"
config.redis.minIdle: "Minimum idle connections in pool"
config.redis.maxIdle: "Maximum idle connections in pool"
config.redis.maxActive: "Maximum active connections (0=unlimited)"
config.redis.maxConnLifetime: "Maximum connection lifetime"
config.redis.idleTimeout: "Idle connection timeout"
config.redis.waitTimeout: "Wait timeout for connection from pool"
config.redis.dialTimeout: "Dial connection timeout for TCP"
config.redis.readTimeout: "Read timeout for TCP"
config.redis.writeTimeout: "Write timeout for TCP"
config.redis.masterName: "Master name for Redis Sentinel mode"
config.redis.tls: "Enable TLS connection"
config.redis.tlsSkipVerify: "Skip TLS server name verification"
config.redis.slaveOnly: "Route all commands to slave read-only nodes"
config.redis.cluster: "Enable cluster mode"
config.redis.protocol: "RESP protocol version (2 or 3)"
# Logger Module
config.logger.flags: "Extra flags for logging output features"
config.logger.timeFormat: "Logging time format pattern"
config.logger.path: "Logging directory path for file output"
config.logger.file: "Log file name pattern (supports datetime like {Y-m-d})"
config.logger.level: "Output level bitmask (DEBU=16, INFO=32, NOTI=64, WARN=128, ERRO=256, CRIT=512, ALL=992)"
config.logger.prefix: "Prefix string for every log entry"
config.logger.headerPrint: "Print log header"
config.logger.stdoutPrint: "Output log to stdout"
config.logger.levelPrint: "Print level string in log"
config.logger.stSkip: "Stack skip count from end point"
config.logger.stStatus: "Stack trace status (1=enabled, 0=disabled)"
config.logger.stFilter: "Stack string filter pattern"
config.logger.rotateSize: "Rotate log file when size exceeds (bytes, 0=disabled)"
config.logger.rotateExpire: "Rotate log file when mtime exceeds this duration"
config.logger.rotateBackupLimit: "Maximum rotated backup files (0=no limit)"
config.logger.rotateBackupExpire: "Rotated backup file expiration"
config.logger.rotateBackupCompress: "Gzip compression level for backup (0=no compression)"
config.logger.rotateCheckInterval: "Async rotate check interval"
config.logger.stdoutColorDisabled: "Disable color output to stdout"
config.logger.writerColorEnable: "Enable color output to writer"
# Viewer Module
config.viewer.paths: "Template search paths"
config.viewer.data: "Global template variables"
config.viewer.defaultFile: "Default template file for parsing"
config.viewer.delimiters: "Custom template delimiters (left, right)"
config.viewer.autoEncode: "Auto HTML encode for XSS safety"

View File

@ -1,137 +0,0 @@
# GoFrame 配置字段描述 - 中文
# 用于配置可视化编辑器的字段描述
# Server 模块
config.server.name: "服务名称,用于服务注册与发现"
config.server.address: "服务监听地址,格式如 ':端口' 或 'IP:端口',多个地址用 ',' 分隔"
config.server.httpsAddr: "HTTPS 监听地址,多个地址用 ',' 分隔"
config.server.endpoints: "自定义服务注册端点,为空则使用 Address"
config.server.httpsCertPath: "HTTPS 证书文件路径"
config.server.httpsKeyPath: "HTTPS 密钥文件路径"
config.server.readTimeout: "HTTP 请求读取超时时间(包含请求体)"
config.server.writeTimeout: "HTTP 响应写入超时时间"
config.server.idleTimeout: "HTTP 空闲连接超时时间"
config.server.maxHeaderBytes: "请求头最大字节数(默认 10KB"
config.server.keepAlive: "是否启用 HTTP Keep-Alive"
config.server.serverAgent: "HTTP 响应头中的 Server 字段值"
config.server.rewrites: "URI 重写规则映射"
config.server.indexFiles: "静态文件夹的索引文件列表"
config.server.indexFolder: "是否允许列出文件夹内容"
config.server.serverRoot: "静态文件服务根目录"
config.server.searchPaths: "静态文件服务的额外搜索路径"
config.server.fileServerEnabled: "静态文件服务全局开关"
config.server.cookieMaxAge: "Cookie 最大存活时间"
config.server.cookiePath: "Cookie 路径,也影响 Session ID 存储"
config.server.cookieDomain: "Cookie 域名,也影响 Session ID 存储"
config.server.cookieSameSite: "Cookie SameSite 属性"
config.server.cookieSecure: "Cookie Secure 标记"
config.server.cookieHttpOnly: "Cookie HttpOnly 标记"
config.server.sessionIdName: "Session ID 在 Cookie 中的名称"
config.server.sessionMaxAge: "Session 最大存活时间"
config.server.sessionPath: "Session 文件存储目录"
config.server.sessionCookieMaxAge: "Session ID Cookie 存活时间0 表示随浏览器关闭)"
config.server.sessionCookieOutput: "是否自动将 Session ID 输出到 Cookie"
config.server.logPath: "日志文件存储目录"
config.server.logLevel: "日志级别all, debug, info, notice, warning, error, critical"
config.server.logStdout: "是否将日志输出到标准输出"
config.server.errorStack: "错误日志是否记录堆栈信息"
config.server.errorLogEnabled: "是否启用错误日志文件"
config.server.errorLogPattern: "错误日志文件名模式"
config.server.accessLogEnabled: "是否启用访问日志文件"
config.server.accessLogPattern: "访问日志文件名模式"
config.server.pprofEnabled: "是否启用 PProf 性能分析"
config.server.pprofPattern: "PProf 路由模式"
config.server.openapiPath: "OpenApi 规范文件路径"
config.server.swaggerPath: "Swagger UI 路由路径"
config.server.swaggerUITemplate: "自定义 Swagger UI 模板"
config.server.graceful: "是否启用优雅重载"
config.server.gracefulTimeout: "优雅重载时父进程最大存活时间(秒)"
config.server.gracefulShutdownTimeout: "优雅关闭时最大等待时间(秒)"
config.server.clientMaxBodySize: "客户端请求体最大字节数(默认 8MB"
config.server.formParsingMemory: "表单解析最大内存缓冲(默认 1MB"
config.server.nameToUriType: "方法名转 URI 类型0=默认, 1=全名, 2=全小写, 3=驼峰)"
config.server.routeOverWrite: "是否允许覆盖重复路由"
config.server.dumpRouterMap: "服务启动时是否打印路由表"
# Database 数据库模块
config.database.host: "数据库服务器地址IP 或域名)"
config.database.port: "数据库服务器端口号"
config.database.user: "数据库认证用户名"
config.database.pass: "数据库认证密码"
config.database.name: "默认数据库名称"
config.database.type: "数据库类型mysql, pgsql, sqlite, mssql, oracle, clickhouse, dm"
config.database.link: "自定义连接字符串(包含所有配置信息)"
config.database.extra: "第三方驱动的额外配置选项"
config.database.role: "主从架构中的节点角色master/slave"
config.database.debug: "是否启用调试模式"
config.database.prefix: "数据表名称前缀"
config.database.dryRun: "模拟模式:仅执行 SELECT跳过增删改操作"
config.database.weight: "负载均衡权重"
config.database.charset: "数据库字符集"
config.database.protocol: "网络连接协议"
config.database.timezone: "时区设置"
config.database.namespace: "Schema 命名空间(如 PostgreSQL 的 schema"
config.database.maxIdle: "连接池最大空闲连接数"
config.database.maxOpen: "连接池最大连接数0 表示无限制)"
config.database.maxLifeTime: "连接最大生存时间"
config.database.maxIdleTime: "连接最大空闲时间"
config.database.queryTimeout: "查询操作SELECT超时时间"
config.database.execTimeout: "执行操作INSERT/UPDATE/DELETE超时时间"
config.database.tranTimeout: "事务块超时时间"
config.database.prepareTimeout: "预处理语句超时时间"
config.database.createdAt: "自动创建时间戳字段名"
config.database.updatedAt: "自动更新时间戳字段名"
config.database.deletedAt: "软删除时间戳字段名"
config.database.timeMaintainDisabled: "是否禁用自动时间维护"
# Redis 模块
config.redis.address: "Redis 服务器地址,集群模式下多个地址用 ',' 分隔"
config.redis.db: "Redis 数据库索引0-15"
config.redis.user: "认证用户名Redis 6.0+ 支持)"
config.redis.pass: "认证密码"
config.redis.sentinelUser: "哨兵认证用户名"
config.redis.sentinelPass: "哨兵认证密码"
config.redis.minIdle: "连接池最小空闲连接数"
config.redis.maxIdle: "连接池最大空闲连接数"
config.redis.maxActive: "最大活跃连接数0 表示无限制)"
config.redis.maxConnLifetime: "连接最大生存时间"
config.redis.idleTimeout: "空闲连接超时时间"
config.redis.waitTimeout: "从连接池获取连接的等待超时"
config.redis.dialTimeout: "TCP 连接拨号超时"
config.redis.readTimeout: "TCP 读取超时"
config.redis.writeTimeout: "TCP 写入超时"
config.redis.masterName: "Redis 哨兵模式下的主节点名称"
config.redis.tls: "是否启用 TLS 加密连接"
config.redis.tlsSkipVerify: "是否跳过 TLS 服务器名称验证"
config.redis.slaveOnly: "是否将所有命令路由到从节点"
config.redis.cluster: "是否启用集群模式"
config.redis.protocol: "RESP 协议版本2 或 3"
# Logger 日志模块
config.logger.flags: "额外的日志输出特性标志"
config.logger.timeFormat: "日志时间格式"
config.logger.path: "日志文件目录路径"
config.logger.file: "日志文件名模式(支持日期变量如 {Y-m-d}"
config.logger.level: "日志级别位掩码DEBU=16, INFO=32, NOTI=64, WARN=128, ERRO=256, CRIT=512, ALL=992"
config.logger.prefix: "日志内容前缀字符串"
config.logger.headerPrint: "是否打印日志头部"
config.logger.stdoutPrint: "是否输出到标准输出"
config.logger.levelPrint: "是否在日志中打印级别字符串"
config.logger.stSkip: "堆栈跳过层数"
config.logger.stStatus: "堆栈跟踪状态1=启用, 0=禁用)"
config.logger.stFilter: "堆栈字符串过滤模式"
config.logger.rotateSize: "日志文件滚动大小字节0 表示不滚动)"
config.logger.rotateExpire: "日志文件滚动时间间隔"
config.logger.rotateBackupLimit: "滚动备份文件最大数量0 表示不限制)"
config.logger.rotateBackupExpire: "滚动备份文件过期时间"
config.logger.rotateBackupCompress: "备份文件 Gzip 压缩级别0 表示不压缩)"
config.logger.rotateCheckInterval: "异步滚动检查间隔"
config.logger.stdoutColorDisabled: "是否禁用标准输出颜色"
config.logger.writerColorEnable: "是否启用 Writer 颜色输出"
# Viewer 模板引擎模块
config.viewer.paths: "模板文件搜索路径"
config.viewer.data: "全局模板变量"
config.viewer.defaultFile: "默认解析模板文件"
config.viewer.delimiters: "自定义模板分隔符(左, 右)"
config.viewer.autoEncode: "是否自动 HTML 编码以防 XSS"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,881 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoFrame Config Editor</title>
<script src="/static/vue.global.prod.js"></script>
<link href="/static/tailwind.min.css" rel="stylesheet">
<!-- <script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet"> -->
<style>
:root {
--primary: #2563EB;
--primary-light: #3B82F6;
--primary-dark: #1E40AF;
--primary-glow: rgba(37, 99, 235, 0.15);
--bg-sidebar: #0F172A;
--bg-sidebar-hover: #1E293B;
--bg-content: #F1F5F9;
--bg-card: #FFFFFF;
--text-primary: #0F172A;
--text-secondary: #64748B;
--text-light: #F1F5F9;
--text-muted: #94A3B8;
--success: #10B981;
--danger: #EF4444;
--warning: #F59E0B;
--accent: #6366F1;
--border: #E2E8F0;
--border-light: #F1F5F9;
--sidebar-width: 260px;
--header-height: 60px;
--footer-height: 36px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-content);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 10px; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.3); }
@keyframes fadeInUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideInLeft { from { transform: translateX(-8px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes saveAnim { 0% { transform: scale(1); } 30% { transform: scale(0.95); } 60% { transform: scale(1.02); } 100% { transform: scale(1); } }
@keyframes checkmark { 0% { stroke-dashoffset: 24; } 100% { stroke-dashoffset: 0; } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
@keyframes pulseGlow { 0%, 100% { box-shadow: 0 0 0 0 var(--primary-glow); } 50% { box-shadow: 0 0 0 8px transparent; } }
.fade-in-up { animation: fadeInUp 0.4s cubic-bezier(0.22, 1, 0.36, 1); }
.fade-in { animation: fadeIn 0.3s ease-out; }
.slide-in-left { animation: slideInLeft 0.25s cubic-bezier(0.22, 1, 0.36, 1); }
.save-anim { animation: saveAnim 0.4s cubic-bezier(0.22, 1, 0.36, 1); }
.glass { background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(12px); }
.glass-dark { background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(12px); }
.field-input {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
border: 1.5px solid var(--border);
background: #FAFBFC;
font-family: 'Courier New', Consolas, monospace;
font-size: 13px;
}
.field-input:hover { border-color: #CBD5E1; background: #FFFFFF; }
.field-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-glow); outline: none; background: #FFFFFF; }
.field-input.has-error { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); }
.toggle-switch { position: relative; width: 48px; height: 26px; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-slider {
position: absolute; cursor: pointer; inset: 0;
background: #CBD5E1; border-radius: 26px;
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.toggle-slider:before {
content: ""; position: absolute;
height: 20px; width: 20px; left: 3px; bottom: 3px;
background: white; border-radius: 50%;
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
input:checked + .toggle-slider { background: var(--primary); }
input:checked + .toggle-slider:before { transform: translateX(22px); }
.modified-bar {
width: 3px; border-radius: 0 2px 2px 0;
background: linear-gradient(180deg, var(--primary), var(--accent));
position: absolute; left: 0; top: 8px; bottom: 8px;
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.tooltip-wrap { position: relative; }
.tooltip-wrap .tip-text {
visibility: hidden; opacity: 0;
background: var(--bg-sidebar); color: var(--text-light);
padding: 8px 14px; border-radius: 8px; font-size: 12px;
line-height: 1.5;
position: absolute; z-index: 100; bottom: calc(100% + 8px);
left: 50%; transform: translateX(-50%) translateY(4px);
white-space: normal; max-width: 280px; min-width: 120px;
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.tooltip-wrap .tip-text::after {
content: ''; position: absolute; top: 100%; left: 50%;
transform: translateX(-50%);
border: 6px solid transparent; border-top-color: var(--bg-sidebar);
}
.tooltip-wrap:hover .tip-text { visibility: visible; opacity: 1; transform: translateX(-50%) translateY(0); }
.group-card {
transition: border-color 0.25s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.25s cubic-bezier(0.22, 1, 0.36, 1);
border: 1px solid var(--border);
border-radius: 12px;
}
.group-card:hover { border-color: #CBD5E1; box-shadow: 0 4px 16px rgba(0,0,0,0.04); }
.field-row { transition: all 0.2s ease; position: relative; }
.field-row:hover { background: rgba(37, 99, 235, 0.02); }
.module-item {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
position: relative; overflow: hidden;
}
.module-item::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(99, 102, 241, 0.05));
opacity: 0; transition: opacity 0.2s ease;
}
.module-item:hover::before { opacity: 1; }
.module-item.active {
background: linear-gradient(135deg, #2563EB, #4F46E5);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.module-item.active::before { opacity: 0; }
.type-badge { font-family: 'Courier New', Consolas, monospace; font-size: 11px; letter-spacing: 0.3px; }
.btn-save { background: linear-gradient(135deg, #2563EB, #4F46E5); transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); }
.btn-save:hover { background: linear-gradient(135deg, #1D4ED8, #4338CA); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); transform: translateY(-1px); }
.btn-save.saved { background: linear-gradient(135deg, #10B981, #059669); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35); }
.toast {
position: fixed; top: 20px; right: 20px; z-index: 200;
padding: 14px 20px; border-radius: 12px;
font-size: 14px; font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
transform: translateX(120%);
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast.show { transform: translateX(0); }
.toast.success { background: linear-gradient(135deg, #10B981, #059669); color: white; }
.toast.error { background: linear-gradient(135deg, #EF4444, #DC2626); color: white; }
.skeleton {
background: linear-gradient(90deg, #E2E8F0 25%, #F1F5F9 50%, #E2E8F0 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 6px;
}
.status-dot { position: relative; }
.status-dot::after {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; border: 2px solid currentColor;
opacity: 0; animation: pulseGlow 2s ease-in-out infinite;
}
</style>
</head>
<body>
<div id="app">
<div :class="['toast', toast.type, toast.show ? 'show' : '']">
<div class="flex items-center space-x-2">
<svg v-if="toast.type==='success'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ toast.message }}</span>
</div>
</div>
<header class="fixed top-0 left-0 right-0 glass z-50" style="height: var(--header-height); border-bottom: 1px solid rgba(226, 232, 240, 0.8);">
<div class="h-full flex items-center justify-between px-6">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-3">
<div class="w-9 h-9 rounded-xl flex items-center justify-center" style="background: linear-gradient(135deg, #2563EB, #4F46E5); box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);">
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15v-4H7l5-7v4h4l-5 7z"/></svg>
</div>
<div>
<h1 class="text-base font-semibold leading-tight" style="color: var(--text-primary);">GoFrame</h1>
<p class="text-xs" style="color: var(--text-muted);">Config Editor</p>
</div>
</div>
<div class="h-6 w-px bg-gray-200 mx-1"></div>
<span class="text-xs font-medium px-2.5 py-1 rounded-full" style="background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(99, 102, 241, 0.08)); color: var(--primary);">v1.0</span>
</div>
<div class="flex items-center space-x-3">
<div class="relative">
<input v-model="searchQuery" type="text"
:placeholder="lang==='zh-CN' ? '搜索字段...' : 'Search fields...'"
class="w-48 text-sm pl-9 pr-3 py-2 rounded-lg border border-gray-200 bg-gray-50 focus:bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none transition-all">
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<div class="flex items-center bg-gray-100 rounded-lg p-0.5" style="border: 1px solid var(--border);">
<button @click="switchLang('en')" :class="['px-3 py-1.5 rounded-md text-xs font-semibold tracking-wide transition-all', lang==='en' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700']">EN</button>
<button @click="switchLang('zh-CN')" :class="['px-3 py-1.5 rounded-md text-xs font-semibold tracking-wide transition-all', lang==='zh-CN' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700']">中文</button>
</div>
<div class="relative">
<select v-model="exportFormat" class="appearance-none text-sm font-medium border border-gray-200 rounded-lg pl-3 pr-8 py-2 bg-white hover:border-gray-300 focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition-all cursor-pointer" style="color: var(--text-primary);">
<option value="yaml">YAML</option>
<option value="toml">TOML</option>
<option value="json">JSON</option>
</select>
<svg class="absolute right-2.5 top-3 w-3.5 h-3.5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<button @click="saveConfig" :class="['btn-save flex items-center space-x-2 px-5 py-2 rounded-lg text-sm font-semibold text-white', saving ? 'saved save-anim' : '']" :disabled="saving">
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/></svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" style="stroke-dasharray: 24; animation: checkmark 0.4s ease forwards;"/></svg>
<span>{{ saving ? (lang==='zh-CN'?'已保存':'Saved!') : (lang==='zh-CN'?'保存配置':'Save') }}</span>
</button>
</div>
</div>
</header>
<div class="flex" style="padding-top: var(--header-height);">
<aside class="fixed left-0 bottom-0 sidebar-scroll overflow-y-auto" style="top: var(--header-height); width: var(--sidebar-width); background: var(--bg-sidebar); padding-bottom: var(--footer-height);">
<div class="px-5 pt-5 pb-3">
<div class="text-xs font-bold uppercase tracking-widest" style="color: #475569;">
{{ lang==='zh-CN' ? '配置模块' : 'MODULES' }}
</div>
</div>
<div class="px-3 space-y-1">
<div v-for="(schema, idx) in schemas" :key="schema.name" :style="{ animationDelay: idx * 50 + 'ms' }" class="fade-in">
<button @click="selectModule(schema.name)"
:class="['module-item w-full flex items-center justify-between px-3 py-3 rounded-xl text-sm font-medium', activeModule===schema.name ? 'active text-white' : 'text-gray-400 hover:text-gray-200']">
<div class="flex items-center space-x-3 relative z-10">
<span class="w-9 h-9 rounded-lg flex items-center justify-center text-base"
:style="{ background: activeModule===schema.name ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.04)', boxShadow: activeModule===schema.name ? 'inset 0 1px 0 rgba(255,255,255,0.1)' : 'none' }">
{{ moduleIcons[schema.name] }}
</span>
<span class="relative z-10">{{ formatModuleName(schema.name) }}</span>
</div>
<div class="flex items-center space-x-2 relative z-10">
<span v-if="getModuleModifiedCount(schema.name) > 0" class="flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold" style="background: rgba(37, 99, 235, 0.2); color: #93C5FD;">
{{ getModuleModifiedCount(schema.name) }}
</span>
<span class="text-xs opacity-50">{{ schema.fields.length }}</span>
</div>
</button>
<div v-show="activeModule===schema.name" class="ml-4 mt-1 mb-2 space-y-0.5 slide-in-left">
<button v-for="group in schema.groups" :key="group"
@click="scrollToGroup(group)"
:class="['w-full text-left flex items-center space-x-2 px-3 py-1.5 rounded-lg text-xs transition-all relative', activeGroup===group ? 'text-blue-400' : 'text-gray-600 hover:text-gray-400']">
<span class="w-1.5 h-1.5 rounded-full" :style="{ background: groupHasModified(schema, group) ? '#3B82F6' : activeGroup===group ? '#3B82F6' : '#334155' }"></span>
<span>{{ group }}</span>
<span class="ml-auto text-xs opacity-40">{{ getGroupFieldCount(schema, group) }}</span>
</button>
</div>
</div>
</div>
<div class="px-5 py-4 mt-4" style="border-top: 1px solid rgba(255,255,255,0.05);">
<div class="text-xs" style="color: #475569;">
<div class="flex items-center space-x-2 mb-1">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
<span>{{ schemas.length }} {{ lang==='zh-CN' ? '个模块已加载' : 'modules loaded' }}</span>
</div>
<div class="flex items-center space-x-2">
<span class="w-2 h-2 rounded-full" :class="modifiedCount > 0 ? 'bg-blue-500' : 'bg-gray-600'"></span>
<span>{{ modifiedCount }} {{ lang==='zh-CN' ? '处修改' : 'changes' }}</span>
</div>
</div>
</div>
</aside>
<main class="flex-1 min-h-screen" style="margin-left: var(--sidebar-width); padding-bottom: calc(var(--footer-height) + 24px);">
<div v-if="loading" class="p-6">
<div class="max-w-4xl mx-auto space-y-4">
<div class="skeleton h-10 w-64 mb-6"></div>
<div v-for="i in 3" :key="i" class="bg-white rounded-xl border p-6 space-y-4">
<div class="skeleton h-6 w-40"></div>
<div v-for="j in 4" :key="j" class="flex items-center space-x-4">
<div class="skeleton h-4 w-32"></div>
<div class="skeleton h-9 flex-1"></div>
</div>
</div>
</div>
</div>
<div v-else-if="!currentSchema" class="flex items-center justify-center h-64">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style="background: linear-gradient(135deg, rgba(37,99,235,0.08), rgba(99,102,241,0.08));">
<svg class="w-8 h-8" style="color: var(--primary);" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<p class="text-sm font-medium" style="color: var(--text-secondary);">{{ lang==='zh-CN' ? '请从左侧选择一个配置模块' : 'Select a module from the sidebar' }}</p>
</div>
</div>
<div v-else class="p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-6 fade-in-up">
<div class="flex items-center space-x-3 mb-2">
<span class="text-2xl">{{ moduleIcons[currentSchema.name] }}</span>
<h1 class="text-2xl font-bold" style="color: var(--text-primary);">
{{ formatModuleName(currentSchema.name) }}
<span class="text-lg font-normal" style="color: var(--text-muted);">{{ lang==='zh-CN' ? '配置' : 'Configuration' }}</span>
</h1>
</div>
<div class="flex items-center space-x-4 text-sm" style="color: var(--text-secondary);">
<span class="flex items-center space-x-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
<span>{{ filteredFieldCount }} {{ lang==='zh-CN' ? '个字段' : 'fields' }}</span>
</span>
<span class="flex items-center space-x-1">
<span style="color: var(--text-muted);">{{ lang==='zh-CN' ? '配置节点' : 'Node' }}:</span>
<code class="px-2 py-0.5 rounded-md text-xs font-mono" style="background: rgba(37,99,235,0.06); color: var(--primary);">{{ currentSchema.configNode }}</code>
</span>
</div>
<!-- 配置组选择器 -->
<div v-if="showConfigGroupSelector" class="mt-4 flex items-center space-x-3">
<span class="text-sm font-medium" style="color: var(--text-secondary);">
{{ lang==='zh-CN' ? '配置组' : 'Config Group' }}:
</span>
<div class="flex items-center bg-gray-100 rounded-lg p-0.5" style="border: 1px solid var(--border);">
<button v-for="group in configGroups" :key="group"
@click="selectConfigGroup(group)"
:class="['px-3 py-1.5 rounded-md text-xs font-semibold tracking-wide transition-all', activeConfigGroup===group ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700']">
{{ group }}
</button>
</div>
<span v-if="activeConfigGroup" class="text-xs px-2 py-0.5 rounded-full" style="background: rgba(37,99,235,0.08); color: var(--primary);">
{{ activeConfigGroup }}
</span>
</div>
</div>
<div v-for="(group, gIdx) in currentSchema.groups" :key="group" :id="'group-'+group"
:style="{ animationDelay: gIdx * 60 + 'ms' }"
class="group-card bg-white mb-4 overflow-hidden fade-in-up"
v-show="getFilteredGroupFields(group).length > 0">
<button @click="toggleGroup(group)"
class="w-full flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
:style="{ background: expandedGroups[group] ? 'linear-gradient(135deg, rgba(37,99,235,0.1), rgba(99,102,241,0.1))' : '#F8FAFC' }">
<svg :class="['w-4 h-4 transition-all duration-300', expandedGroups[group] ? '' : '-rotate-90']"
:style="{ color: expandedGroups[group] ? 'var(--primary)' : 'var(--text-muted)' }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
<h2 class="text-sm font-semibold tracking-wide" style="color: var(--text-primary);">{{ group }}</h2>
<span class="text-xs font-medium px-2 py-0.5 rounded-full" style="background: var(--border-light); color: var(--text-muted);">{{ getFilteredGroupFields(group).length }}</span>
<span v-if="groupHasModified(currentSchema, group)" class="flex items-center space-x-1 text-xs font-medium px-2 py-0.5 rounded-full" style="background: var(--primary-glow); color: var(--primary);">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
<span>{{ lang==='zh-CN' ? '已修改' : 'Modified' }}</span>
</span>
</div>
<div class="flex items-center space-x-2">
<span v-if="groupHasModified(currentSchema, group)" @click.stop="resetGroup(group)"
class="opacity-0 group-hover:opacity-100 text-xs px-2.5 py-1 rounded-md hover:bg-red-50 transition-all cursor-pointer select-none" style="color: var(--danger);">
{{ lang==='zh-CN' ? '重置分组' : 'Reset Group' }}
</span>
</div>
</button>
<div v-if="expandedGroups[group]" class="border-t" style="border-color: var(--border-light);">
<div v-for="(field, fIdx) in getFilteredGroupFields(group)" :key="field.jsonKey"
:style="{ animationDelay: fIdx * 30 + 'ms' }"
class="field-row px-6 py-4 border-b last:border-0 fade-in" style="border-color: var(--border-light);">
<div v-if="isModified(field)" class="modified-bar"></div>
<div class="flex items-start gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-2 mb-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">{{ field.name }}</span>
<code class="text-xs font-mono px-1.5 py-0.5 rounded" style="background: #F1F5F9; color: var(--text-secondary);">{{ field.jsonKey }}</code>
<span :class="['type-badge px-1.5 py-0.5 rounded', typeColorClass(field.type)]">{{ field.type }}</span>
<span v-if="field.rule" class="type-badge px-1.5 py-0.5 rounded" style="background: rgba(245, 158, 11, 0.08); color: #D97706;">{{ field.rule }}</span>
</div>
<p class="text-xs leading-relaxed" style="color: var(--text-secondary);">{{ getFieldDescription(field) }}</p>
<div v-if="field.default" class="flex items-center mt-1.5 text-xs" style="color: var(--text-muted);">
<span>{{ lang==='zh-CN' ? '默认' : 'Default' }}:</span>
<code class="ml-1 font-mono px-1.5 py-0.5 rounded" style="background: #F8FAFC; color: var(--text-secondary);">{{ field.default }}</code>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0" style="width: 280px;">
<template v-if="field.type === 'bool'">
<div class="flex items-center space-x-3 w-full justify-end">
<span class="text-xs font-medium" :style="{ color: getFieldValue(field) ? 'var(--primary)' : 'var(--text-muted)' }">
{{ getFieldValue(field) ? (lang==='zh-CN'?'开启':'ON') : (lang==='zh-CN'?'关闭':'OFF') }}
</span>
<label class="toggle-switch">
<input type="checkbox" :checked="getFieldValue(field)" @change="setFieldValue(field, $event.target.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</template>
<template v-else-if="field.type === 'duration'">
<input type="text"
:value="getFieldValue(field) !== undefined ? getFieldValue(field) : ''"
@input="setFieldValue(field, $event.target.value)"
@blur="validateField(field)"
:placeholder="field.default || 'e.g. 30s, 1m, 1h'"
class="field-input w-full text-sm px-3 py-2 rounded-lg">
</template>
<template v-else-if="field.type === 'int' || field.type === 'float'">
<input type="number"
:value="getFieldValue(field) !== undefined ? getFieldValue(field) : ''"
@input="setFieldValue(field, $event.target.value)"
@blur="validateField(field)"
:placeholder="field.default || '0'"
class="field-input w-full text-sm px-3 py-2 rounded-lg">
</template>
<template v-else-if="field.type === 'map' || field.type.startsWith('[]')">
<textarea
:value="getFieldValue(field) !== undefined ? JSON.stringify(getFieldValue(field), null, 2) : ''"
@input="setJSONFieldValue(field, $event.target.value)"
@blur="validateField(field)"
:placeholder="field.default || (field.type === 'map' ? '{key: value}' : '[item1, item2]')"
rows="2"
class="field-input w-full text-sm px-3 py-2 rounded-lg resize-y"
style="min-height: 36px;"></textarea>
</template>
<template v-else>
<input type="text"
:value="getFieldValue(field) !== undefined ? getFieldValue(field) : ''"
@input="setFieldValue(field, $event.target.value)"
@blur="validateField(field)"
:placeholder="field.default || ''"
class="field-input w-full text-sm px-3 py-2 rounded-lg">
</template>
<button v-if="isModified(field)" @click="resetField(field)"
class="tooltip-wrap p-2 rounded-lg hover:bg-red-50 transition-all flex-shrink-0 group">
<svg class="w-4 h-4 transition-colors" style="color: var(--text-muted);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span class="tip-text">{{ lang==='zh-CN' ? '恢复默认值' : 'Reset to default' }}</span>
</button>
</div>
</div>
<div v-if="fieldErrors[activeModule+'.'+field.jsonKey]" class="mt-2 flex items-center space-x-1.5 text-xs" style="color: var(--danger);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ fieldErrors[activeModule+'.'+field.jsonKey] }}</span>
</div>
</div>
</div>
</div>
<div v-if="searchQuery && filteredFieldCount === 0" class="text-center py-12">
<svg class="w-12 h-12 mx-auto mb-3" style="color: var(--text-muted);" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<p class="text-sm font-medium" style="color: var(--text-secondary);">
{{ lang==='zh-CN' ? '没有找到匹配的字段' : 'No matching fields found' }}
</p>
</div>
</div>
</div>
</main>
</div>
<footer class="fixed bottom-0 left-0 right-0 glass flex items-center justify-between px-6 text-xs z-40"
style="height: var(--footer-height); border-top: 1px solid rgba(226, 232, 240, 0.8); color: var(--text-secondary);">
<div class="flex items-center space-x-4">
<span v-if="configFilePath" class="flex items-center space-x-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="font-mono">{{ configFilePath }}</span>
</span>
<span v-else class="flex items-center space-x-1.5" style="color: var(--text-muted);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ lang==='zh-CN' ? '未加载配置文件' : 'No config file loaded' }}</span>
</span>
</div>
<div class="flex items-center space-x-4">
<span v-if="modifiedCount > 0" class="flex items-center space-x-1.5 font-medium" style="color: var(--primary);">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 status-dot"></span>
<span>{{ modifiedCount }} {{ lang==='zh-CN' ? '处修改' : 'change(s)' }}</span>
</span>
<span v-if="lastSaved" class="flex items-center space-x-1.5" style="color: var(--success);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>{{ lang==='zh-CN' ? '已保存' : 'Saved' }} {{ lastSaved }}</span>
</span>
<span class="font-mono opacity-60">GoFrame Config Editor v1.0</span>
</div>
</footer>
</div>
<script>
const { createApp, ref, computed, onMounted, reactive, nextTick } = Vue;
createApp({
setup() {
const schemas = ref([]);
const activeModule = ref('');
const activeGroup = ref('');
const activeConfigGroup = ref(''); // 当前选择的配置组,如 "default", "cache", "disk"
const lang = ref('en');
const i18nData = ref({});
const configData = ref({});
const editedValues = reactive({});
const fieldErrors = reactive({});
const expandedGroups = reactive({});
const loading = ref(true);
const saving = ref(false);
const configFilePath = ref('');
const configFileType = ref('yaml');
const exportFormat = ref('yaml');
const lastSaved = ref('');
const searchQuery = ref('');
const toast = reactive({ show: false, type: 'success', message: '' });
const moduleIcons = { server: '\u{1F310}', database: '\u{1F5C4}', redis: '\u26A1', logger: '\u{1F4DD}', viewer: '\u{1F3A8}' };
const currentSchema = computed(() => schemas.value.find(s => s.name === activeModule.value));
// 计算当前模块的配置组列表
const configGroups = computed(() => {
if (!currentSchema.value || !configData.value) return [];
const moduleData = configData.value[currentSchema.value.configNode];
if (!moduleData || typeof moduleData !== 'object') return [];
// 获取所有配置组名称(排除非对象类型的键)
const groups = [];
for (const key of Object.keys(moduleData)) {
const val = moduleData[key];
if (val && typeof val === 'object') {
groups.push(key);
}
}
return groups.sort();
});
// 判断是否需要显示配置组选择器(有多个配置组时才显示)
const showConfigGroupSelector = computed(() => configGroups.value.length > 1);
const modifiedCount = computed(() => Object.keys(editedValues).length);
const filteredFieldCount = computed(() => {
if (!currentSchema.value) return 0;
if (!searchQuery.value) return currentSchema.value.fields.length;
return currentSchema.value.fields.filter(f => matchSearch(f)).length;
});
function showToast(type, message) {
toast.type = type; toast.message = message; toast.show = true;
setTimeout(() => { toast.show = false; }, 3000);
}
function formatModuleName(name) { return name.charAt(0).toUpperCase() + name.slice(1); }
function matchSearch(field) {
if (!searchQuery.value) return true;
const q = searchQuery.value.toLowerCase();
const desc = lang.value !== 'en' && field.i18nKey && i18nData.value[field.i18nKey]
? i18nData.value[field.i18nKey] : (field.description || '');
return field.name.toLowerCase().includes(q) ||
field.jsonKey.toLowerCase().includes(q) ||
desc.toLowerCase().includes(q) ||
field.type.toLowerCase().includes(q);
}
function selectModule(name) {
activeModule.value = name;
activeConfigGroup.value = ''; // 切换模块时重置配置组选择
const schema = schemas.value.find(s => s.name === name);
if (schema) {
schema.groups.forEach(g => { if (!(g in expandedGroups)) expandedGroups[g] = true; });
}
// 自动选择第一个配置组
nextTick(() => {
if (configGroups.value.length > 0) {
activeConfigGroup.value = configGroups.value[0];
}
});
}
// 选择配置组
function selectConfigGroup(groupName) {
activeConfigGroup.value = groupName;
}
function toggleGroup(group) { expandedGroups[group] = !expandedGroups[group]; }
function scrollToGroup(group) {
activeGroup.value = group;
expandedGroups[group] = true;
nextTick(() => {
const el = document.getElementById('group-' + group);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
function getGroupFields(group) {
if (!currentSchema.value) return [];
return currentSchema.value.fields.filter(f => f.group === group);
}
function getFilteredGroupFields(group) { return getGroupFields(group).filter(f => matchSearch(f)); }
function getGroupFieldCount(schema, group) { return schema.fields.filter(f => f.group === group).length; }
// findValueInObj returns the value for key from obj using a case-insensitive match.
function findValueInObj(obj, key) {
if (!obj || typeof obj !== 'object') return undefined;
if (key in obj) return obj[key];
const lk = key.toLowerCase();
for (const k of Object.keys(obj)) {
if (k.toLowerCase() === lk) return obj[k];
}
return undefined;
}
// resolveConfigValue finds the actual config value for a field,
// handling nested structures like database.default.host and redis.default.address.
function resolveConfigValue(moduleData, field) {
if (!moduleData) return undefined;
const key = field.jsonKey;
// 如果选择了配置组,直接从该组获取值
if (activeConfigGroup.value && moduleData[activeConfigGroup.value]) {
const groupData = moduleData[activeConfigGroup.value];
// 处理数组形式(如 database.default 是数组)
if (Array.isArray(groupData) && groupData.length > 0) {
return findValueInObj(groupData[0], key);
}
// 处理对象形式(如 redis.default 是对象)
if (typeof groupData === 'object') {
return findValueInObj(groupData, key);
}
}
// 1. Direct lookup.
const direct = findValueInObj(moduleData, key);
if (direct !== undefined) return direct;
// 2. Nested group lookup — GoFrame stores database/redis configs under
// group names like "default", e.g. database.default.host.
for (const groupKey of Object.keys(moduleData)) {
const groupVal = moduleData[groupKey];
if (!groupVal || typeof groupVal !== 'object') continue;
// Object form: { host: "...", port: "..." }
if (!Array.isArray(groupVal)) {
const nested = findValueInObj(groupVal, key);
if (nested !== undefined) return nested;
}
// Array form: [{ host: "...", port: "..." }] — take first element.
if (Array.isArray(groupVal) && groupVal.length > 0) {
const nested = findValueInObj(groupVal[0], key);
if (nested !== undefined) return nested;
}
}
return undefined;
}
function getFieldValue(field) {
const key = activeModule.value + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '') + field.jsonKey;
if (key in editedValues) return editedValues[key];
if (!currentSchema.value) return undefined;
const moduleData = configData.value[currentSchema.value.configNode];
return resolveConfigValue(moduleData, field);
}
function setFieldValue(field, value) {
const key = activeModule.value + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '') + field.jsonKey;
editedValues[key] = value;
delete fieldErrors[key];
}
function setJSONFieldValue(field, rawValue) {
const key = activeModule.value + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '') + field.jsonKey;
try {
editedValues[key] = JSON.parse(rawValue);
delete fieldErrors[key];
} catch (e) {
if (rawValue.trim() === '') {
delete editedValues[key];
delete fieldErrors[key];
} else {
fieldErrors[key] = lang.value === 'zh-CN' ? '无效的 JSON 格式' : 'Invalid JSON format';
}
}
}
function resetField(field) {
const key = activeModule.value + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '') + field.jsonKey;
delete editedValues[key];
delete fieldErrors[key];
}
function resetGroup(group) { getGroupFields(group).forEach(f => resetField(f)); }
function isModified(field) {
const key = activeModule.value + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '') + field.jsonKey;
return key in editedValues;
}
function groupHasModified(schema, group) {
const prefix = schema.name + '.' + (activeConfigGroup.value ? activeConfigGroup.value + '.' : '');
return schema.fields.filter(f => f.group === group).some(f => (prefix + f.jsonKey) in editedValues);
}
function getModuleModifiedCount(moduleName) {
return Object.keys(editedValues).filter(k => k.startsWith(moduleName + '.')).length;
}
function getFieldDescription(field) {
if (lang.value !== 'en' && field.i18nKey && i18nData.value[field.i18nKey]) {
return i18nData.value[field.i18nKey];
}
return field.description || '';
}
function typeColorClass(type) {
const map = { 'string': 'bg-green-50 text-green-700', 'int': 'bg-blue-50 text-blue-700',
'bool': 'bg-purple-50 text-purple-700', 'duration': 'bg-amber-50 text-amber-700',
'float': 'bg-indigo-50 text-indigo-700', 'map': 'bg-pink-50 text-pink-700' };
if (type.startsWith('[]')) return 'bg-orange-50 text-orange-700';
return map[type] || 'bg-gray-100 text-gray-600';
}
function validateField(field) {
const key = activeModule.value + '.' + field.jsonKey;
const value = getFieldValue(field);
if (field.rule && field.rule.includes('required') && (!value || value === '')) {
fieldErrors[key] = (lang.value === 'zh-CN' ? '此字段为必填项' : 'This field is required');
return;
}
delete fieldErrors[key];
}
async function switchLang(newLang) {
lang.value = newLang;
if (newLang !== 'en') {
try {
const res = await fetch('/api/i18n/' + newLang);
const json = await res.json();
if (json.code === 0) i18nData.value = json.data || {};
} catch (e) { console.error('Failed to load i18n', e); }
}
}
// setNestedValue updates fieldKey inside moduleData in-place,
// handling nested structures like database.default.host and redis.default.address.
function setNestedValue(moduleData, fieldKey, value) {
const lk = fieldKey.toLowerCase();
// 1. Direct update.
for (const k of Object.keys(moduleData)) {
if (k.toLowerCase() === lk) { moduleData[k] = value; return true; }
}
// 2. Look inside nested group objects.
for (const groupKey of Object.keys(moduleData)) {
const groupVal = moduleData[groupKey];
if (!groupVal || typeof groupVal !== 'object') continue;
if (!Array.isArray(groupVal)) {
for (const k of Object.keys(groupVal)) {
if (k.toLowerCase() === lk) { groupVal[k] = value; return true; }
}
}
if (Array.isArray(groupVal) && groupVal.length > 0) {
for (const k of Object.keys(groupVal[0])) {
if (k.toLowerCase() === lk) { groupVal[0][k] = value; return true; }
}
}
}
return false;
}
// 获取指定模块的配置组列表
function configGroupsForModule(moduleName) {
const schema = schemas.value.find(s => s.name === moduleName);
if (!schema || !configData.value) return [];
const moduleData = configData.value[schema.configNode];
if (!moduleData || typeof moduleData !== 'object') return [];
const groups = [];
for (const key of Object.keys(moduleData)) {
const val = moduleData[key];
if (val && typeof val === 'object') {
groups.push(key);
}
}
return groups;
}
async function saveConfig() {
const merged = JSON.parse(JSON.stringify(configData.value || {}));
for (const [key, value] of Object.entries(editedValues)) {
const parts = key.split('.');
const moduleName = parts[0];
const schema = schemas.value.find(s => s.name === moduleName);
if (!schema) continue;
const configNode = schema.configNode;
if (!merged[configNode]) merged[configNode] = {};
// 检查是否有配置组(如 redis.cache.address 中的 cache
const configGroups = configGroupsForModule(moduleName);
if (configGroups.length > 0 && parts.length >= 3) {
// 格式: module.configGroup.fieldKey
const groupName = parts[1];
const fieldKey = parts.slice(2).join('.');
if (!merged[configNode][groupName]) {
merged[configNode][groupName] = {};
}
const groupData = merged[configNode][groupName];
if (Array.isArray(groupData) && groupData.length > 0) {
groupData[0][fieldKey] = value;
} else if (typeof groupData === 'object') {
groupData[fieldKey] = value;
}
} else {
// 格式: module.fieldKey无配置组
const fieldKey = parts.slice(1).join('.');
if (!setNestedValue(merged[configNode], fieldKey, value)) {
merged[configNode][fieldKey] = value;
}
}
}
saving.value = true;
try {
const res = await fetch('/api/config/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: merged,
filePath: configFilePath.value || '',
fileType: exportFormat.value,
})
});
const json = await res.json();
if (json.code === 0) {
lastSaved.value = new Date().toLocaleTimeString();
if (json.data && json.data.filePath) configFilePath.value = json.data.filePath;
showToast('success', lang.value === 'zh-CN' ? '配置保存成功' : 'Configuration saved successfully');
} else {
showToast('error', (lang.value === 'zh-CN' ? '保存失败: ' : 'Save failed: ') + json.message);
}
} catch (e) {
showToast('error', (lang.value === 'zh-CN' ? '保存错误: ' : 'Save error: ') + e.message);
}
setTimeout(() => { saving.value = false; }, 2000);
}
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (!saving.value) saveConfig();
}
});
onMounted(async () => {
try {
const [schemaRes, cfgRes] = await Promise.all([
fetch('/api/schemas'),
fetch('/api/config')
]);
const schemaJson = await schemaRes.json();
if (schemaJson.code === 0) {
schemas.value = schemaJson.data || [];
if (schemas.value.length > 0) selectModule(schemas.value[0].name);
}
const cfgJson = await cfgRes.json();
if (cfgJson.code === 0 && cfgJson.data) {
configData.value = cfgJson.data.config || {};
configFilePath.value = cfgJson.data.filePath || '';
configFileType.value = cfgJson.data.fileType || 'yaml';
exportFormat.value = cfgJson.data.fileType || 'yaml';
}
} catch (e) {
console.error('Failed to load data', e);
showToast('error', 'Failed to load configuration data');
}
loading.value = false;
});
return {
schemas, activeModule, activeGroup, activeConfigGroup, lang, i18nData, configData,
editedValues, fieldErrors, expandedGroups, loading, saving, configFilePath,
configFileType, exportFormat, lastSaved, moduleIcons, searchQuery,
toast, currentSchema, modifiedCount, filteredFieldCount,
configGroups, showConfigGroupSelector,
showToast, formatModuleName, matchSearch,
selectModule, selectConfigGroup, toggleGroup, scrollToGroup,
getGroupFields, getFilteredGroupFields, getGroupFieldCount,
getFieldValue, setFieldValue, setJSONFieldValue, resetField, resetGroup,
isModified, groupHasModified, getModuleModifiedCount,
getFieldDescription, typeColorClass, validateField,
switchLang, saveConfig,
};
}
}).mount('#app');
</script>
</body>
</html>

View File

@ -9,7 +9,6 @@ package mariadb_test
import (
"context"
"fmt"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/mariadb/v2"
@ -22,22 +21,17 @@ import (
)
const (
TableSize = 10
TableName = "user"
TestSchema1 = "test1"
TestSchema2 = "test2"
TestPartitionDB = "test3"
TableNamePrefix1 = "gf_"
TestDbUser = "root"
TestDbPass = "12345678"
CreateTime = "2018-10-24 10:00:00"
TableSize = 10
TableName = "user"
TestSchema1 = "test1"
TestSchema2 = "test2"
TestDbPass = "12345678"
CreateTime = "2018-10-24 10:00:00"
)
var (
db gdb.DB
db2 gdb.DB
db3 gdb.DB
dbPrefix gdb.DB
dbInvalid gdb.DB
ctx = context.TODO()
)
@ -48,26 +42,10 @@ func init() {
Link: fmt.Sprintf("mariadb:root:%s@tcp(127.0.0.1:3307)/?loc=Local&parseTime=true", TestDbPass),
TranTimeout: time.Second * 3,
}
partitionDefault := gdb.ConfigNode{
Link: fmt.Sprintf("mariadb:root:%s@tcp(127.0.0.1:3307)/?loc=Local&parseTime=true", TestDbPass),
Debug: true,
TranTimeout: time.Second * 3,
err := gdb.AddConfigNode(gdb.DefaultGroupName, nodeDefault)
if err != nil {
panic(err)
}
nodePrefix := gdb.ConfigNode{
Link: fmt.Sprintf("mariadb:root:%s@tcp(127.0.0.1:3307)/?loc=Local&parseTime=true", TestDbPass),
TranTimeout: time.Second * 3,
}
nodePrefix.Prefix = TableNamePrefix1
nodeInvalid := gdb.ConfigNode{
Link: fmt.Sprintf("mariadb:root:%s@tcp(127.0.0.1:3317)/?loc=Local&parseTime=true", TestDbPass),
TranTimeout: time.Second * 3,
}
gdb.AddConfigNode("test", nodeDefault)
gdb.AddConfigNode("prefix", nodePrefix)
gdb.AddConfigNode("nodeinvalid", nodeInvalid)
gdb.AddConfigNode("partition", partitionDefault)
gdb.AddConfigNode(gdb.DefaultGroupName, nodeDefault)
// Default db.
if r, err := gdb.NewByGroup(); err != nil {
@ -82,27 +60,15 @@ func init() {
if _, err := db.Exec(ctx, fmt.Sprintf(schemaTemplate, TestSchema2)); err != nil {
gtest.Error(err)
}
if _, err := db.Exec(ctx, fmt.Sprintf(schemaTemplate, TestPartitionDB)); err != nil {
gtest.Error(err)
}
db = db.Schema(TestSchema1)
db2 = db.Schema(TestSchema2)
db3 = db.Schema(TestPartitionDB)
// Prefix db.
if r, err := gdb.NewByGroup("prefix"); err != nil {
gtest.Error(err)
} else {
dbPrefix = r
}
if _, err := dbPrefix.Exec(ctx, fmt.Sprintf(schemaTemplate, TestSchema1)); err != nil {
gtest.Error(err)
}
if _, err := dbPrefix.Exec(ctx, fmt.Sprintf(schemaTemplate, TestSchema2)); err != nil {
gtest.Error(err)
}
dbPrefix = dbPrefix.Schema(TestSchema1)
// Invalid db.
// Invalid db (wrong port for testing error handling).
nodeInvalid := gdb.ConfigNode{
Link: fmt.Sprintf("mariadb:root:%s@tcp(127.0.0.1:3317)/?loc=Local&parseTime=true", TestDbPass),
TranTimeout: time.Second * 3,
}
gdb.AddConfigNode("nodeinvalid", nodeInvalid)
if r, err := gdb.NewByGroup("nodeinvalid"); err != nil {
gtest.Error(err)
} else {
@ -174,61 +140,3 @@ func dropTableWithDb(db gdb.DB, table string) {
gtest.Error(err)
}
}
func Test_PartitionTable(t *testing.T) {
dropShopDBTable()
createShopDBTable()
insertShopDBData()
// defer dropShopDBTable()
gtest.C(t, func(t *gtest.T) {
data, err := db3.Ctx(ctx).Model("dbx_order").Partition("p3", "p4").All()
t.AssertNil(err)
dataLen := len(data)
t.Assert(dataLen, 5)
data, err = db3.Ctx(ctx).Model("dbx_order").Partition("p3").All()
t.AssertNil(err)
dataLen = len(data)
t.Assert(dataLen, 5)
})
}
func createShopDBTable() {
sql := `CREATE TABLE dbx_order (
id int(11) NOT NULL,
sales_date date DEFAULT NULL,
amount decimal(10,2) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (YEAR(sales_date))
(PARTITION p1 VALUES LESS THAN (2020) ENGINE = InnoDB,
PARTITION p2 VALUES LESS THAN (2021) ENGINE = InnoDB,
PARTITION p3 VALUES LESS THAN (2022) ENGINE = InnoDB,
PARTITION p4 VALUES LESS THAN MAXVALUE ENGINE = InnoDB);`
_, err := db3.Exec(ctx, sql)
if err != nil {
gtest.Fatal(err.Error())
}
}
func insertShopDBData() {
data := g.Slice{}
year := 2020
for i := 1; i <= 5; i++ {
year++
data = append(data, g.Map{
"id": i,
"sales_date": fmt.Sprintf("%d-09-21", year),
"amount": fmt.Sprintf("1%d.21", i),
})
}
_, err := db3.Model("dbx_order").Ctx(ctx).Data(data).Insert()
if err != nil {
gtest.Error(err)
}
}
func dropShopDBTable() {
if _, err := db3.Exec(ctx, "DROP TABLE IF EXISTS `dbx_order`"); err != nil {
gtest.Error(err)
}
}

View File

@ -1,83 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/test/gtest"
)
func Test_Instance(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
_, err := gdb.Instance("none")
t.AssertNE(err, nil)
db, err := gdb.Instance()
t.AssertNil(err)
err1 := db.PingMaster()
err2 := db.PingSlave()
t.Assert(err1, nil)
t.Assert(err2, nil)
})
}
func Test_Func_FormatSqlWithArgs(t *testing.T) {
// mysql
gtest.C(t, func(t *gtest.T) {
var s string
s = gdb.FormatSqlWithArgs("select * from table where id>=? and sex=?", []any{100, 1})
t.Assert(s, "select * from table where id>=100 and sex=1")
})
// mssql
gtest.C(t, func(t *gtest.T) {
var s string
s = gdb.FormatSqlWithArgs("select * from table where id>=@p1 and sex=@p2", []any{100, 1})
t.Assert(s, "select * from table where id>=100 and sex=1")
})
// pgsql
gtest.C(t, func(t *gtest.T) {
var s string
s = gdb.FormatSqlWithArgs("select * from table where id>=$1 and sex=$2", []any{100, 1})
t.Assert(s, "select * from table where id>=100 and sex=1")
})
// oracle
gtest.C(t, func(t *gtest.T) {
var s string
s = gdb.FormatSqlWithArgs("select * from table where id>=:v1 and sex=:v2", []any{100, 1})
t.Assert(s, "select * from table where id>=100 and sex=1")
})
}
func Test_Func_ToSQL(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
sql, err := gdb.ToSQL(ctx, func(ctx context.Context) error {
value, err := db.Ctx(ctx).Model(TableName).Fields("nickname").Where("id", 1).Value()
t.Assert(value, nil)
return err
})
t.AssertNil(err)
t.Assert(sql, "SELECT `nickname` FROM `user` WHERE `id`=1 LIMIT 1")
})
}
func Test_Func_CatchSQL(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
array, err := gdb.CatchSQL(ctx, func(ctx context.Context) error {
value, err := db.Ctx(ctx).Model(table).Fields("nickname").Where("id", 1).Value()
t.Assert(value, "name_1")
return err
})
t.AssertNil(err)
t.AssertGE(len(array), 1)
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,321 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"fmt"
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/test/gtest"
)
func createDuplicateTable(table ...string) string {
var name string
if len(table) > 0 {
name = table[0]
} else {
name = fmt.Sprintf(`duplicate_table_%d`, gtime.TimestampNano())
}
dropTable(name)
if _, err := db.Exec(ctx, fmt.Sprintf(`
CREATE TABLE %s (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
email varchar(100) NOT NULL,
username varchar(45) NULL,
score int(10) unsigned DEFAULT 0,
login_count int(10) unsigned DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`, name)); err != nil {
gtest.Fatal(err)
}
return name
}
func Test_OnDuplicateKeyUpdate_Basic(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// First insert
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1", 100)
t.AssertNil(err)
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1")
t.Assert(one["score"], 100)
// Duplicate insert - should update
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1_updated", 200)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1_updated")
t.Assert(one["score"], 200)
// Verify only one record exists
count, err := db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 1)
})
}
func Test_OnDuplicateKeyUpdate_Increment(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// First insert
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, login_count) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE login_count = login_count + 1",
table,
), "user1@example.com", "user1", 1)
t.AssertNil(err)
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["login_count"], 1)
// Duplicate - increment login_count
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, login_count) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE login_count = login_count + 1",
table,
), "user1@example.com", "user1", 1)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["login_count"], 2)
// Third time
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, login_count) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE login_count = login_count + 1",
table,
), "user1@example.com", "user1", 1)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["login_count"], 3)
})
}
func Test_OnDuplicateKeyUpdate_MultipleColumns(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// First insert
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score, login_count) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score), login_count = login_count + 1",
table,
), "user1@example.com", "user1", 100, 1)
t.AssertNil(err)
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1")
t.Assert(one["score"], 100)
t.Assert(one["login_count"], 1)
// Duplicate - update multiple columns
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score, login_count) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score), login_count = login_count + 1",
table,
), "user1@example.com", "user1_v2", 200, 1)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1_v2")
t.Assert(one["score"], 200)
t.Assert(one["login_count"], 2)
})
}
func Test_OnDuplicateKeyUpdate_Batch(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert multiple records
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1", 100,
"user2@example.com", "user2", 200,
"user3@example.com", "user3", 300)
t.AssertNil(err)
count, err := db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 3)
// Update with duplicate - should update specific records
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?), (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1_updated", 150,
"user2@example.com", "user2_updated", 250)
t.AssertNil(err)
// Still 3 records
count, err = db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 3)
// Verify updates
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1_updated")
t.Assert(one["score"], 150)
one, err = db.Model(table).Where("email", "user2@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user2_updated")
t.Assert(one["score"], 250)
// user3 unchanged
one, err = db.Model(table).Where("email", "user3@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user3")
t.Assert(one["score"], 300)
})
}
func Test_OnDuplicateKeyUpdate_ConditionalUpdate(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// First insert
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE score = IF(VALUES(score) > score, VALUES(score), score)",
table,
), "user1@example.com", "user1", 100)
t.AssertNil(err)
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["score"], 100)
// Try to update with lower score - should not update
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE score = IF(VALUES(score) > score, VALUES(score), score)",
table,
), "user1@example.com", "user1", 50)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["score"], 100) // Still 100
// Update with higher score - should update
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE score = IF(VALUES(score) > score, VALUES(score), score)",
table,
), "user1@example.com", "user1", 150)
t.AssertNil(err)
one, err = db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["score"], 150) // Updated to 150
})
}
func Test_OnDuplicateKeyUpdate_WithTransaction(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Transaction with ON DUPLICATE KEY UPDATE
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// First insert
_, err := tx.Exec(fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1", 100)
if err != nil {
return err
}
// Duplicate in same transaction
_, err = tx.Exec(fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1_updated", 200)
return err
})
t.AssertNil(err)
// Verify final state
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1_updated")
t.Assert(one["score"], 200)
count, err := db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 1)
})
}
func Test_OnDuplicateKeyUpdate_MixedInsertUpdate(t *testing.T) {
table := createDuplicateTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// First batch insert
_, err := db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?), (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1", 100,
"user2@example.com", "user2", 200)
t.AssertNil(err)
count, err := db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 2)
// Mixed batch: one duplicate, one new
_, err = db.Exec(ctx, fmt.Sprintf(
"INSERT INTO %s (email, username, score) VALUES (?, ?, ?), (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), score = VALUES(score)",
table,
), "user1@example.com", "user1_updated", 150,
"user3@example.com", "user3", 300)
t.AssertNil(err)
// Should have 3 records now
count, err = db.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 3)
// Verify user1 was updated
one, err := db.Model(table).Where("email", "user1@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user1_updated")
t.Assert(one["score"], 150)
// Verify user3 was inserted
one, err = db.Model(table).Where("email", "user3@example.com").One()
t.AssertNil(err)
t.Assert(one["username"], "user3")
t.Assert(one["score"], 300)
})
}

View File

@ -1,394 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"fmt"
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/test/gtest"
)
func createJSONTable(table ...string) string {
var name string
if len(table) > 0 {
name = table[0]
} else {
name = fmt.Sprintf(`json_table_%d`, gtime.TimestampNano())
}
dropTable(name)
if _, err := db.Exec(ctx, fmt.Sprintf(`
CREATE TABLE %s (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
name varchar(45) NULL,
config json NULL,
metadata json NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`, name)); err != nil {
gtest.Fatal(err)
}
return name
}
func Test_JSON_Insert_Map(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
data := g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
"lang": "zh-CN",
},
"metadata": g.Map{
"tags": g.Slice{"admin", "developer"},
"level": 5,
},
}
result, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
n, _ := result.LastInsertId()
t.Assert(n, 1)
one, err := db.Model(table).WherePri(1).One()
t.AssertNil(err)
t.Assert(one["name"], "user1")
t.AssertNE(one["config"], nil)
t.AssertNE(one["metadata"], nil)
})
}
func Test_JSON_Insert_String(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
data := g.Map{
"name": "user2",
"config": `{"theme":"light","lang":"en-US"}`,
"metadata": `{"tags":["user"],"level":1}`,
}
result, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
n, _ := result.LastInsertId()
t.Assert(n, 1)
one, err := db.Model(table).WherePri(1).One()
t.AssertNil(err)
t.Assert(one["name"], "user2")
t.AssertNE(one["config"], nil)
t.AssertNE(one["metadata"], nil)
})
}
func Test_JSON_Insert_Null(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
data := g.Map{
"name": "user3",
"config": nil,
"metadata": nil,
}
result, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
n, _ := result.LastInsertId()
t.Assert(n, 1)
one, err := db.Model(table).WherePri(1).One()
t.AssertNil(err)
t.Assert(one["name"], "user3")
t.Assert(one["config"], nil)
t.Assert(one["metadata"], nil)
})
}
func Test_JSON_Update(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert initial data
_, err := db.Model(table).Data(g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
},
}).Insert()
t.AssertNil(err)
// Update JSON column
result, err := db.Model(table).Data(g.Map{
"config": g.Map{
"theme": "light",
"lang": "en-US",
},
}).WherePri(1).Update()
t.AssertNil(err)
n, _ := result.RowsAffected()
t.Assert(n, 1)
one, err := db.Model(table).WherePri(1).One()
t.AssertNil(err)
t.AssertNE(one["config"], nil)
})
}
func Test_JSON_Extract_Where(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert test data
data := g.Slice{
g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
"lang": "zh-CN",
},
},
g.Map{
"name": "user2",
"config": g.Map{
"theme": "light",
"lang": "en-US",
},
},
g.Map{
"name": "user3",
"config": g.Map{
"theme": "dark",
"lang": "en-US",
},
},
}
_, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query by JSON field using JSON_EXTRACT
all, err := db.Model(table).Where("JSON_EXTRACT(config, '$.theme') = ?", "dark").All()
t.AssertNil(err)
t.Assert(len(all), 2)
all, err = db.Model(table).Where("JSON_EXTRACT(config, '$.lang') = ?", "en-US").All()
t.AssertNil(err)
t.Assert(len(all), 2)
})
}
func Test_JSON_Extract_Select(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert test data
_, err := db.Model(table).Data(g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
"lang": "zh-CN",
},
"metadata": g.Map{
"level": 5,
},
}).Insert()
t.AssertNil(err)
// Select with JSON_EXTRACT
one, err := db.Model(table).Fields("name, JSON_EXTRACT(config, '$.theme') as theme, JSON_EXTRACT(metadata, '$.level') as level").WherePri(1).One()
t.AssertNil(err)
t.Assert(one["name"], "user1")
t.AssertNE(one["theme"], nil)
t.AssertNE(one["level"], nil)
})
}
func Test_JSON_Array_Query(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data with JSON array
data := g.Slice{
g.Map{
"name": "user1",
"metadata": g.Map{
"tags": g.Slice{"admin", "developer"},
},
},
g.Map{
"name": "user2",
"metadata": g.Map{
"tags": g.Slice{"user"},
},
},
g.Map{
"name": "user3",
"metadata": g.Map{
"tags": g.Slice{"admin", "user"},
},
},
}
_, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query by JSON array contains
all, err := db.Model(table).Where("JSON_CONTAINS(metadata, ?, '$.tags')", `"admin"`).All()
t.AssertNil(err)
t.Assert(len(all), 2)
})
}
func Test_JSON_Batch_Insert(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
data := g.Slice{
g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
},
},
g.Map{
"name": "user2",
"config": g.Map{
"theme": "light",
},
},
g.Map{
"name": "user3",
"config": nil,
},
}
result, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
n, _ := result.RowsAffected()
t.Assert(n, 3)
all, err := db.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 3)
})
}
func Test_JSON_Scan_To_Struct(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
type Config struct {
Theme string `json:"theme"`
Lang string `json:"lang"`
}
type User struct {
Id int
Name string
Config *Config
}
gtest.C(t, func(t *gtest.T) {
// Insert data
_, err := db.Model(table).Data(g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
"lang": "zh-CN",
},
}).Insert()
t.AssertNil(err)
// Scan to struct
var user User
err = db.Model(table).WherePri(1).Scan(&user)
t.AssertNil(err)
t.Assert(user.Name, "user1")
t.AssertNE(user.Config, nil)
if user.Config != nil {
t.Assert(user.Config.Theme, "dark")
t.Assert(user.Config.Lang, "zh-CN")
}
})
}
func Test_JSON_Complex_Structure(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert complex nested JSON
data := g.Map{
"name": "user1",
"config": g.Map{
"ui": g.Map{
"theme": "dark",
"fontSize": g.Map{
"base": 14,
"code": 12,
},
},
"editor": g.Map{
"tabSize": 4,
"wordWrap": true,
},
},
}
result, err := db.Model(table).Data(data).Insert()
t.AssertNil(err)
n, _ := result.LastInsertId()
t.Assert(n, 1)
// Query nested JSON path
one, err := db.Model(table).Fields("JSON_EXTRACT(config, '$.ui.theme') as theme, JSON_EXTRACT(config, '$.ui.fontSize.base') as base_font").WherePri(1).One()
t.AssertNil(err)
t.AssertNE(one["theme"], nil)
t.AssertNE(one["base_font"], nil)
})
}
func Test_JSON_Transaction(t *testing.T) {
table := createJSONTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Insert in transaction
_, err := tx.Model(table).Ctx(ctx).Data(g.Map{
"name": "user1",
"config": g.Map{
"theme": "dark",
},
}).Insert()
if err != nil {
return err
}
// Update in transaction
_, err = tx.Model(table).Ctx(ctx).Data(g.Map{
"config": g.Map{
"theme": "light",
},
}).WherePri(1).Update()
return err
})
t.AssertNil(err)
// Verify data
one, err := db.Model(table).WherePri(1).One()
t.AssertNil(err)
t.Assert(one["name"], "user1")
t.AssertNE(one["config"], nil)
})
}

View File

@ -1,228 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/test/gtest"
)
// Test_Model_Lock tests the Lock method with custom lock clause
func Test_Model_Lock(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Test basic Lock with FOR UPDATE
one, err := db.Model(table).Lock("FOR UPDATE").Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
// Test Lock with legacy LOCK IN SHARE MODE (MariaDB/MySQL compatible)
one, err = db.Model(table).Lock("LOCK IN SHARE MODE").Where("id", 3).One()
t.AssertNil(err)
t.Assert(one["id"], 3)
// Test Lock with predefined constants
one, err = db.Model(table).Lock(gdb.LockForUpdate).Where("id", 4).One()
t.AssertNil(err)
t.Assert(one["id"], 4)
})
}
// Test_Model_LockUpdate tests the LockUpdate convenience method
func Test_Model_LockUpdate(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Test LockUpdate is equivalent to Lock("FOR UPDATE")
one, err := db.Model(table).LockUpdate().Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
t.Assert(one["passport"], "user_1")
// Test LockUpdate with All()
all, err := db.Model(table).LockUpdate().Where("id<?", 4).Order("id").All()
t.AssertNil(err)
t.Assert(len(all), 3)
t.Assert(all[0]["id"], 1)
t.Assert(all[2]["id"], 3)
// Test LockUpdate with Count()
count, err := db.Model(table).LockUpdate().Where("id>?", 5).Count()
t.AssertNil(err)
t.Assert(count, 5)
})
}
// Test_Model_LockUpdateSkipLocked tests the LockUpdateSkipLocked convenience method
// Note: SKIP LOCKED requires MariaDB 10.6+, skipped for compatibility
// func Test_Model_LockUpdateSkipLocked(t *testing.T) {
// table := createInitTable()
// defer dropTable(table)
//
// gtest.C(t, func(t *gtest.T) {
// // Test LockUpdateSkipLocked basic usage
// one, err := db.Model(table).LockUpdateSkipLocked().Where("id", 1).One()
// t.AssertNil(err)
// t.Assert(one["id"], 1)
//
// // Test LockUpdateSkipLocked with All()
// all, err := db.Model(table).LockUpdateSkipLocked().Where("id>?", 7).Order("id").All()
// t.AssertNil(err)
// t.Assert(len(all), 3)
// })
// }
// Test_Model_LockShared tests the LockShared convenience method
func Test_Model_LockShared(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Test LockShared is equivalent to Lock("LOCK IN SHARE MODE")
one, err := db.Model(table).LockShared().Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
// Test LockShared with All()
all, err := db.Model(table).LockShared().Where("id<=?", 5).Order("id").All()
t.AssertNil(err)
t.Assert(len(all), 5)
t.Assert(all[0]["id"], 1)
t.Assert(all[4]["id"], 5)
})
}
// Test_Model_Lock_WithTransaction tests Lock methods within transaction
func Test_Model_Lock_WithTransaction(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Lock row for update in transaction
one, err := tx.Model(table).LockUpdate().Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
// Update the locked row
_, err = tx.Model(table).Data(g.Map{"nickname": "updated_name"}).Where("id", 1).Update()
t.AssertNil(err)
// Verify update
updated, err := tx.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(updated["nickname"], "updated_name")
return nil
})
t.AssertNil(err)
// Verify transaction committed successfully
one, err := db.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["nickname"], "updated_name")
})
}
// Test_Model_Lock_ReleaseAfterCommit tests lock is released after transaction commit
func Test_Model_Lock_ReleaseAfterCommit(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Start transaction and lock a row
tx, err := db.Begin(ctx)
t.AssertNil(err)
one, err := tx.Model(table).LockUpdate().Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
// Update within transaction
_, err = tx.Model(table).Data(g.Map{"nickname": "tx_update"}).Where("id", 1).Update()
t.AssertNil(err)
// Commit transaction - this should release the lock
err = tx.Commit()
t.AssertNil(err)
// Another query should succeed without blocking
one, err = db.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["nickname"], "tx_update")
})
}
// Test_Model_Lock_ReleaseAfterRollback tests lock is released after transaction rollback
func Test_Model_Lock_ReleaseAfterRollback(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Start transaction and lock a row
tx, err := db.Begin(ctx)
t.AssertNil(err)
one, err := tx.Model(table).LockUpdate().Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["id"], 1)
// Update within transaction
_, err = tx.Model(table).Data(g.Map{"nickname": "rollback_update"}).Where("id", 1).Update()
t.AssertNil(err)
// Rollback transaction - this should release the lock and discard changes
err = tx.Rollback()
t.AssertNil(err)
// Verify original value is preserved
one, err = db.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["nickname"], "name_1")
})
}
// Test_Model_Lock_ChainedMethods tests Lock with other chained methods
func Test_Model_Lock_ChainedMethods(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
// Lock with Fields
one, err := db.Model(table).Fields("id,passport").LockUpdate().Where("id", 1).One()
t.AssertNil(err)
t.Assert(len(one), 2)
t.Assert(one["id"], 1)
t.Assert(one["passport"], "user_1")
// Lock with Order and Limit
all, err := db.Model(table).LockShared().Where("id>?", 5).Order("id desc").Limit(3).All()
t.AssertNil(err)
t.Assert(len(all), 3)
t.Assert(all[0]["id"], 10)
t.Assert(all[2]["id"], 8)
// Lock with Group and Having
all, err = db.Model(table).Fields("LEFT(passport,4) as prefix, COUNT(*) as cnt").
LockUpdate().
Group("prefix").
Having("cnt>?", 0).
Order("prefix").
All()
t.AssertNil(err)
t.Assert(len(all), 1)
t.Assert(all[0]["prefix"], "user")
t.Assert(all[0]["cnt"], 10)
})
}

View File

@ -1,324 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"fmt"
"sync"
"testing"
"github.com/gogf/gf/v2/container/garray"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/test/gtest"
"github.com/gogf/gf/v2/util/guid"
)
func Test_Master_Slave(t *testing.T) {
var err error
gtest.C(t, func(t *gtest.T) {
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `master` CHARACTER SET UTF8")
t.AssertNil(err)
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `slave` CHARACTER SET UTF8")
t.AssertNil(err)
})
defer func() {
_, _ = db.Exec(ctx, "DROP DATABASE `master`")
_, _ = db.Exec(ctx, "DROP DATABASE `slave`")
}()
var (
configKey = guid.S()
configGroup = gdb.ConfigGroup{
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "master",
Type: "mariadb",
Role: "master",
Debug: true,
Weight: 100,
},
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "slave",
Type: "mariadb",
Role: "slave",
Debug: true,
Weight: 100,
},
}
)
gdb.SetConfigGroup(configKey, configGroup)
masterSlaveDB := g.DB(configKey)
gtest.C(t, func(t *gtest.T) {
table := "table_" + guid.S()
createTableWithDb(masterSlaveDB.Schema("master"), table)
createTableWithDb(masterSlaveDB.Schema("slave"), table)
defer dropTableWithDb(masterSlaveDB.Schema("master"), table)
defer dropTableWithDb(masterSlaveDB.Schema("slave"), table)
// Data insert to master.
array := garray.New(true)
for i := 1; i <= TableSize; i++ {
array.Append(g.Map{
"id": i,
"passport": fmt.Sprintf(`user_%d`, i),
"password": fmt.Sprintf(`pass_%d`, i),
"nickname": fmt.Sprintf(`name_%d`, i),
"create_time": gtime.NewFromStr(CreateTime).String(),
})
}
_, err = masterSlaveDB.Model(table).Data(array).Insert()
t.AssertNil(err)
var count int
// Auto slave.
count, err = masterSlaveDB.Model(table).Count()
t.AssertNil(err)
t.Assert(count, int64(0))
// slave.
count, err = masterSlaveDB.Model(table).Slave().Count()
t.AssertNil(err)
t.Assert(count, int64(0))
// master.
count, err = masterSlaveDB.Model(table).Master().Count()
t.AssertNil(err)
t.Assert(count, int64(TableSize))
})
}
// Test_Master_Slave_Concurrent_ReadWrite tests concurrent read/write routing
func Test_Master_Slave_Concurrent_ReadWrite(t *testing.T) {
var err error
gtest.C(t, func(t *gtest.T) {
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `master` CHARACTER SET UTF8")
t.AssertNil(err)
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `slave` CHARACTER SET UTF8")
t.AssertNil(err)
})
defer func() {
_, _ = db.Exec(ctx, "DROP DATABASE `master`")
_, _ = db.Exec(ctx, "DROP DATABASE `slave`")
}()
var (
configKey = guid.S()
configGroup = gdb.ConfigGroup{
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "master",
Type: "mariadb",
Role: "master",
Weight: 100,
},
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "slave",
Type: "mariadb",
Role: "slave",
Weight: 100,
},
}
)
gdb.SetConfigGroup(configKey, configGroup)
masterSlaveDB := g.DB(configKey)
gtest.C(t, func(t *gtest.T) {
table := "table_" + guid.S()
createTableWithDb(masterSlaveDB.Schema("master"), table)
createTableWithDb(masterSlaveDB.Schema("slave"), table)
defer dropTableWithDb(masterSlaveDB.Schema("master"), table)
defer dropTableWithDb(masterSlaveDB.Schema("slave"), table)
var wg sync.WaitGroup
concurrency := 10
// Concurrent writes to master
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func(id int) {
defer wg.Done()
_, err := masterSlaveDB.Model(table).Insert(g.Map{
"passport": fmt.Sprintf("concurrent_%d", id),
"password": fmt.Sprintf("pass_%d", id),
"nickname": fmt.Sprintf("name_%d", id),
})
t.AssertNil(err)
}(i)
}
wg.Wait()
// Verify writes went to master
count, err := masterSlaveDB.Model(table).Master().Count()
t.AssertNil(err)
t.Assert(count, concurrency)
})
}
// Test_Master_Slave_Transaction_Routing tests transaction routing to master
func Test_Master_Slave_Transaction_Routing(t *testing.T) {
var err error
gtest.C(t, func(t *gtest.T) {
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `master` CHARACTER SET UTF8")
t.AssertNil(err)
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `slave` CHARACTER SET UTF8")
t.AssertNil(err)
})
defer func() {
_, _ = db.Exec(ctx, "DROP DATABASE `master`")
_, _ = db.Exec(ctx, "DROP DATABASE `slave`")
}()
var (
configKey = guid.S()
configGroup = gdb.ConfigGroup{
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "master",
Type: "mariadb",
Role: "master",
Weight: 100,
},
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "slave",
Type: "mariadb",
Role: "slave",
Weight: 100,
},
}
)
gdb.SetConfigGroup(configKey, configGroup)
masterSlaveDB := g.DB(configKey)
gtest.C(t, func(t *gtest.T) {
table := "table_" + guid.S()
createTableWithDb(masterSlaveDB.Schema("master"), table)
createTableWithDb(masterSlaveDB.Schema("slave"), table)
defer dropTableWithDb(masterSlaveDB.Schema("master"), table)
defer dropTableWithDb(masterSlaveDB.Schema("slave"), table)
// Transaction should route to master
err := masterSlaveDB.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model(table).Insert(g.Map{
"passport": "tx_user",
"password": "tx_pass",
"nickname": "tx_name",
})
if err != nil {
return err
}
// Read within transaction should also use master
count, err := tx.Model(table).Count()
t.AssertNil(err)
t.Assert(count, 1)
return nil
})
t.AssertNil(err)
// Verify data is in master
count, err := masterSlaveDB.Model(table).Master().Count()
t.AssertNil(err)
t.Assert(count, 1)
})
}
// Test_Master_Slave_Explicit_Selection tests explicit master/slave selection
func Test_Master_Slave_Explicit_Selection(t *testing.T) {
var err error
gtest.C(t, func(t *gtest.T) {
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `master` CHARACTER SET UTF8")
t.AssertNil(err)
_, err = db.Exec(ctx, "CREATE DATABASE IF NOT EXISTS `slave` CHARACTER SET UTF8")
t.AssertNil(err)
})
defer func() {
_, _ = db.Exec(ctx, "DROP DATABASE `master`")
_, _ = db.Exec(ctx, "DROP DATABASE `slave`")
}()
var (
configKey = guid.S()
configGroup = gdb.ConfigGroup{
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "master",
Type: "mariadb",
Role: "master",
Weight: 100,
},
gdb.ConfigNode{
Host: "127.0.0.1",
Port: "3307",
User: "root",
Pass: "12345678",
Name: "slave",
Type: "mariadb",
Role: "slave",
Weight: 100,
},
}
)
gdb.SetConfigGroup(configKey, configGroup)
masterSlaveDB := g.DB(configKey)
gtest.C(t, func(t *gtest.T) {
table := "table_" + guid.S()
createTableWithDb(masterSlaveDB.Schema("master"), table)
createTableWithDb(masterSlaveDB.Schema("slave"), table)
defer dropTableWithDb(masterSlaveDB.Schema("master"), table)
defer dropTableWithDb(masterSlaveDB.Schema("slave"), table)
// Insert to master
_, err := masterSlaveDB.Model(table).Master().Insert(g.Map{
"passport": "explicit_test",
"password": "pass",
"nickname": "name",
})
t.AssertNil(err)
// Explicitly read from slave (should be empty)
count, err := masterSlaveDB.Model(table).Slave().Count()
t.AssertNil(err)
t.Assert(count, 0)
// Explicitly read from master (should have data)
count, err = masterSlaveDB.Model(table).Master().Count()
t.AssertNil(err)
t.Assert(count, 1)
})
}

View File

@ -1,115 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"testing"
"github.com/gogf/gf/v2/test/gtest"
)
// Test_TableFields_Basic tests basic TableFields functionality
func Test_TableFields_Basic(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
fields, err := db.TableFields(ctx, table)
t.AssertNil(err)
t.AssertGT(len(fields), 0)
// Verify common fields exist
_, ok := fields["id"]
t.Assert(ok, true)
_, ok = fields["passport"]
t.Assert(ok, true)
_, ok = fields["password"]
t.Assert(ok, true)
_, ok = fields["nickname"]
t.Assert(ok, true)
_, ok = fields["create_time"]
t.Assert(ok, true)
})
}
// Test_TableFields_Schema tests TableFields with explicit schema
func Test_TableFields_Schema(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
fields, err := db.TableFields(ctx, table, TestSchema1)
t.AssertNil(err)
t.AssertGT(len(fields), 0)
// Verify field properties
idField, ok := fields["id"]
t.Assert(ok, true)
t.Assert(idField.Name, "id")
t.AssertGT(idField.Index, -1)
})
}
// Test_HasField_Positive tests HasField for existing field
func Test_HasField_Positive(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
has, err := db.GetCore().HasField(ctx, table, "id")
t.AssertNil(err)
t.Assert(has, true)
has, err = db.GetCore().HasField(ctx, table, "passport")
t.AssertNil(err)
t.Assert(has, true)
})
}
// Test_HasField_Negative tests HasField for non-existent field
func Test_HasField_Negative(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
has, err := db.GetCore().HasField(ctx, table, "non_exist_field")
t.AssertNil(err)
t.Assert(has, false)
})
}
// Test_HasField_Schema tests HasField with explicit schema
func Test_HasField_Schema(t *testing.T) {
table := createInitTable()
defer dropTable(table)
gtest.C(t, func(t *gtest.T) {
has, err := db.GetCore().HasField(ctx, table, "id", TestSchema1)
t.AssertNil(err)
t.Assert(has, true)
})
}
// Test_QuoteWord_Basic tests basic QuoteWord functionality
func Test_QuoteWord_Basic(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
quoted := db.GetCore().QuoteWord("user")
t.Assert(quoted, "`user`")
quoted = db.GetCore().QuoteWord("user_table")
t.Assert(quoted, "`user_table`")
})
}
// Test_QuoteWord_AlreadyQuoted tests QuoteWord with already quoted words
func Test_QuoteWord_AlreadyQuoted(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
// If already quoted, should not double quote
quoted := db.GetCore().QuoteWord("`user`")
t.Assert(quoted, "`user`")
})
}

View File

@ -1,364 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package mariadb_test
import (
"context"
"fmt"
"testing"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/test/gtest"
)
func createRangePartitionTable(table ...string) string {
var name string
if len(table) > 0 {
name = table[0]
} else {
name = fmt.Sprintf(`partition_range_%d`, gtime.TimestampNano())
}
if _, err := db3.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS `%s`", name)); err != nil {
gtest.Fatal(err)
}
if _, err := db3.Exec(ctx, fmt.Sprintf(`
CREATE TABLE %s (
id int(11) NOT NULL,
sales_date date DEFAULT NULL,
amount decimal(10,2) DEFAULT NULL,
region varchar(50) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (YEAR(sales_date))
(PARTITION p2020 VALUES LESS THAN (2021) ENGINE = InnoDB,
PARTITION p2021 VALUES LESS THAN (2022) ENGINE = InnoDB,
PARTITION p2022 VALUES LESS THAN (2023) ENGINE = InnoDB,
PARTITION p2023 VALUES LESS THAN (2024) ENGINE = InnoDB,
PARTITION p_future VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
`, name)); err != nil {
gtest.Fatal(err)
}
return name
}
func createHashPartitionTable(table ...string) string {
var name string
if len(table) > 0 {
name = table[0]
} else {
name = fmt.Sprintf(`partition_hash_%d`, gtime.TimestampNano())
}
if _, err := db3.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS `%s`", name)); err != nil {
gtest.Fatal(err)
}
if _, err := db3.Exec(ctx, fmt.Sprintf(`
CREATE TABLE %s (
id int(11) NOT NULL,
user_id int(11) NOT NULL,
username varchar(50) DEFAULT NULL,
email varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY HASH (user_id)
PARTITIONS 4;
`, name)); err != nil {
gtest.Fatal(err)
}
return name
}
func createListPartitionTable(table ...string) string {
var name string
if len(table) > 0 {
name = table[0]
} else {
name = fmt.Sprintf(`partition_list_%d`, gtime.TimestampNano())
}
if _, err := db3.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS `%s`", name)); err != nil {
gtest.Fatal(err)
}
if _, err := db3.Exec(ctx, fmt.Sprintf(`
CREATE TABLE %s (
id int(11) NOT NULL,
region_code int(11) NOT NULL,
city varchar(50) DEFAULT NULL,
population int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY LIST (region_code)
(PARTITION p_north VALUES IN (1,2,3) ENGINE = InnoDB,
PARTITION p_south VALUES IN (4,5,6) ENGINE = InnoDB,
PARTITION p_east VALUES IN (7,8,9) ENGINE = InnoDB,
PARTITION p_west VALUES IN (10,11,12) ENGINE = InnoDB);
`, name)); err != nil {
gtest.Fatal(err)
}
return name
}
func dropPartitionTable(table string) {
if _, err := db3.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS `%s`", table)); err != nil {
gtest.Error(err)
}
}
func Test_Partition_Range_Insert_And_Query(t *testing.T) {
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data across different partitions
data := g.Slice{
g.Map{"id": 1, "sales_date": "2020-06-15", "amount": 1000.50, "region": "North"},
g.Map{"id": 2, "sales_date": "2021-03-20", "amount": 2000.75, "region": "South"},
g.Map{"id": 3, "sales_date": "2022-09-10", "amount": 3000.00, "region": "East"},
g.Map{"id": 4, "sales_date": "2023-12-01", "amount": 4000.25, "region": "West"},
g.Map{"id": 5, "sales_date": "2024-01-15", "amount": 5000.00, "region": "North"},
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query all data
all, err := db3.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 5)
// Query specific year (should hit specific partition)
result, err := db3.Model(table).Where("YEAR(sales_date) = ?", 2022).All()
t.AssertNil(err)
t.Assert(len(result), 1)
t.Assert(result[0]["id"], 3)
})
}
func Test_Partition_Range_PartitionQuery(t *testing.T) {
// Known limitation: Model.Partition() sets m.partition field but it's not used in SQL generation
// See: database/gdb/gdb_model_select.go lines 735,755 - m.tables is used without PARTITION clause
// TODO: Add PARTITION clause support to GoFrame query builder
t.Skip("Partition clause in SELECT queries not yet supported in GoFrame query builder")
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data
data := g.Slice{
g.Map{"id": 1, "sales_date": "2020-06-15", "amount": 1000.50},
g.Map{"id": 2, "sales_date": "2021-03-20", "amount": 2000.75},
g.Map{"id": 3, "sales_date": "2022-09-10", "amount": 3000.00},
g.Map{"id": 4, "sales_date": "2023-12-01", "amount": 4000.25},
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query specific partition
result, err := db3.Model(table).Partition("p2022").All()
t.AssertNil(err)
t.Assert(len(result), 1)
t.Assert(result[0]["id"], 3)
// Query multiple partitions
result, err = db3.Model(table).Partition("p2021", "p2022").All()
t.AssertNil(err)
t.Assert(len(result), 2)
})
}
func Test_Partition_Hash_Insert_And_Distribution(t *testing.T) {
table := createHashPartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data that will be distributed across hash partitions
data := g.Slice{}
for i := 1; i <= 20; i++ {
data = append(data, g.Map{
"id": i,
"user_id": i * 10,
"username": fmt.Sprintf("user_%d", i),
"email": fmt.Sprintf("user%d@example.com", i),
})
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query all data
all, err := db3.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 20)
// Query specific user_id (will hit specific partition based on hash)
result, err := db3.Model(table).Where("user_id", 100).One()
t.AssertNil(err)
t.Assert(result["username"], "user_10")
})
}
func Test_Partition_List_Insert_And_Query(t *testing.T) {
// Known limitation: Model.Partition() sets m.partition field but it's not used in SQL generation
// See: database/gdb/gdb_model_select.go lines 735,755 - m.tables is used without PARTITION clause
// TODO: Add PARTITION clause support to GoFrame query builder
t.Skip("Partition clause in SELECT queries not yet supported in GoFrame query builder")
table := createListPartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data for different regions
data := g.Slice{
g.Map{"id": 1, "region_code": 1, "city": "Beijing", "population": 2154},
g.Map{"id": 2, "region_code": 2, "city": "Harbin", "population": 1063},
g.Map{"id": 3, "region_code": 5, "city": "Guangzhou", "population": 1868},
g.Map{"id": 4, "region_code": 7, "city": "Shanghai", "population": 2428},
g.Map{"id": 5, "region_code": 10, "city": "Chengdu", "population": 2093},
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Query all
all, err := db3.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 5)
// Query specific partition (north region)
result, err := db3.Model(table).Partition("p_north").All()
t.AssertNil(err)
t.Assert(len(result), 2)
// Query specific partition (south region)
result, err = db3.Model(table).Partition("p_south").All()
t.AssertNil(err)
t.Assert(len(result), 1)
t.Assert(result[0]["city"], "Guangzhou")
})
}
func Test_Partition_Range_Update(t *testing.T) {
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data
_, err := db3.Model(table).Data(g.Map{
"id": 1,
"sales_date": "2022-06-15",
"amount": 1000.00,
"region": "North",
}).Insert()
t.AssertNil(err)
// Update data within same partition
result, err := db3.Model(table).Data(g.Map{
"amount": 1500.00,
"region": "South",
}).Where("id", 1).Update()
t.AssertNil(err)
n, _ := result.RowsAffected()
t.Assert(n, 1)
// Verify update
one, err := db3.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["amount"], "1500.00")
t.Assert(one["region"], "South")
})
}
func Test_Partition_Range_Delete(t *testing.T) {
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data
data := g.Slice{
g.Map{"id": 1, "sales_date": "2020-06-15", "amount": 1000.50},
g.Map{"id": 2, "sales_date": "2021-03-20", "amount": 2000.75},
g.Map{"id": 3, "sales_date": "2022-09-10", "amount": 3000.00},
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Delete from specific partition
result, err := db3.Model(table).Where("YEAR(sales_date) = ?", 2021).Delete()
t.AssertNil(err)
n, _ := result.RowsAffected()
t.Assert(n, 1)
// Verify deletion
all, err := db3.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 2)
// Verify remaining data
result2, err := db3.Model(table).Where("YEAR(sales_date) = ?", 2021).All()
t.AssertNil(err)
t.Assert(len(result2), 0)
})
}
func Test_Partition_Transaction(t *testing.T) {
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Transaction with partitioned table
err := db3.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Insert across multiple partitions
data := g.Slice{
g.Map{"id": 1, "sales_date": "2020-06-15", "amount": 1000.50},
g.Map{"id": 2, "sales_date": "2021-03-20", "amount": 2000.75},
g.Map{"id": 3, "sales_date": "2022-09-10", "amount": 3000.00},
}
_, err := tx.Model(table).Ctx(ctx).Data(data).Insert()
if err != nil {
return err
}
// Update in transaction
_, err = tx.Model(table).Ctx(ctx).Data(g.Map{
"amount": 1500.00,
}).Where("id", 1).Update()
return err
})
t.AssertNil(err)
// Verify transaction committed
all, err := db3.Model(table).All()
t.AssertNil(err)
t.Assert(len(all), 3)
one, err := db3.Model(table).Where("id", 1).One()
t.AssertNil(err)
t.Assert(one["amount"], "1500.00")
})
}
func Test_Partition_Range_Count_And_Sum(t *testing.T) {
table := createRangePartitionTable()
defer dropPartitionTable(table)
gtest.C(t, func(t *gtest.T) {
// Insert data
data := g.Slice{
g.Map{"id": 1, "sales_date": "2020-06-15", "amount": 1000.00},
g.Map{"id": 2, "sales_date": "2020-09-20", "amount": 1500.00},
g.Map{"id": 3, "sales_date": "2021-03-20", "amount": 2000.00},
g.Map{"id": 4, "sales_date": "2022-09-10", "amount": 3000.00},
}
_, err := db3.Model(table).Data(data).Insert()
t.AssertNil(err)
// Count by year (specific partition)
count, err := db3.Model(table).Where("YEAR(sales_date) = ?", 2020).Count()
t.AssertNil(err)
t.Assert(count, 2)
// Sum across partitions
value, err := db3.Model(table).Fields("SUM(amount) as total").Value()
t.AssertNil(err)
t.AssertGT(value.Float64(), 7000.0) // 1000+1500+2000+3000 = 7500
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
CREATE TABLE `date_time_example` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`year` year DEFAULT NULL COMMENT 'year',
`date` date DEFAULT NULL COMMENT 'Date',
`time` time DEFAULT NULL COMMENT 'time',
`datetime` datetime DEFAULT NULL COMMENT 'datetime',
`timestamp` timestamp NULL DEFAULT NULL COMMENT 'Timestamp',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS `employee`
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT NOT NULL
);
INSERT INTO employee(name, age) VALUES ('John', 30);
INSERT INTO employee(name, age) VALUES ('Mary', 28);

View File

@ -1,35 +0,0 @@
CREATE TABLE `jfy_gift` (
`id` int(0) UNSIGNED NOT NULL AUTO_INCREMENT,
`gift_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '礼品名称',
`at_least_recharge_count` int(0) UNSIGNED NOT NULL DEFAULT 1 COMMENT '最少兑换数量',
`comments` json NOT NULL COMMENT '礼品留言',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '礼品详情',
`cost_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '成本价',
`cover` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '封面',
`covers` json NOT NULL COMMENT '礼品图片库',
`description` varchar(62) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '礼品备注',
`express_type` json NOT NULL COMMENT '配送方式',
`gift_type` int(0) NOT NULL COMMENT '礼品类型1实物2虚拟3优惠券4积分券',
`has_props` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否有多个属性',
`is_limit_sell` tinyint(0) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否限购',
`limit_customer_tags` json NOT NULL COMMENT '语序购买的会员标签',
`limit_sell_custom` tinyint(0) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否开启允许购买的会员标签',
`limit_sell_cycle` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '限购周期',
`limit_sell_cycle_count` int(0) NOT NULL COMMENT '限购期内允许购买的数量',
`limit_sell_type` tinyint(0) NOT NULL COMMENT '限购类型',
`market_price` decimal(10, 2) NOT NULL COMMENT '市场价',
`out_sn` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '内部编码',
`props` json NOT NULL COMMENT '规格',
`skus` json NOT NULL COMMENT 'SKU',
`score_price` decimal(10, 2) NOT NULL COMMENT '兑换所需积分',
`stock` int(0) NOT NULL COMMENT '库存',
`create_at` datetime(0) NOT NULL COMMENT '创建日期',
`store_id` int(0) NOT NULL COMMENT '所属商城',
`status` int(0) UNSIGNED NULL DEFAULT 1 COMMENT '1下架20审核中30复审中99上架',
`view_count` int(0) NOT NULL DEFAULT 0 COMMENT '访问量',
`sell_count` int(0) NULL DEFAULT 0 COMMENT '销量',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `jfy_gift` VALUES (17, 'GIFT', 1, '[{\"name\": \"身份证\", \"field\": \"idcard\", \"required\": false}, {\"name\": \"留言2\", \"field\": \"text\", \"required\": false}]', '<p>礼品详情</p>', 0.00, '', '{\"list\": [{\"uid\": \"vc-upload-1629292486099-3\", \"url\": \"https://cdn.taobao.com/sULsYiwaOPjsKGoBXwKtuewPzACpBDfQ.jpg\", \"name\": \"O1CN01OH6PIP1Oc5ot06U17_!!922361725.jpg\", \"status\": \"done\"}, {\"uid\": \"vc-upload-1629292486099-4\", \"url\": \"https://cdn.taobao.com/lqLHDcrFTgNvlWyXfLYZwmsrODzIBtFH.jpg\", \"name\": \"O1CN018hBckI1Oc5ouc8ppl_!!922361725.jpg\", \"status\": \"done\"}, {\"uid\": \"vc-upload-1629292486099-5\", \"url\": \"https://cdn.taobao.com/pvqyutXckICmHhbPBQtrVLHuMlXuGxUg.jpg\", \"name\": \"O1CN0185Ubp91Oc5osQTTcc_!!922361725.jpg\", \"status\": \"done\"}]}', '支持个性定制的父亲节老师长辈的专属礼物', '[\"快递包邮\", \"同城配送\"]', 1, 0, 0, '[]', 0, 'day', 0, 1, 0.00, '259402', '[{\"name\": \"颜色\", \"values\": [\"红色\", \"蓝色\"]}]', '[{\"name\": \"red\", \"stock\": 10, \"gift_id\": 1, \"cost_price\": 80, \"score_price\": 188, \"market_price\": 388}, {\"name\": \"blue\", \"stock\": 100, \"gift_id\": 2, \"cost_price\": 81, \"score_price\": 200, \"market_price\": 288}]', 10.00, 0, '2021-08-18 21:26:13', 100004, 99, 0, 0);

View File

@ -1,32 +0,0 @@
-- ----------------------------
-- Table structure for parcel_items
-- ----------------------------
DROP TABLE IF EXISTS `parcel_items`;
CREATE TABLE `parcel_items` (
`id` int(11) NOT NULL,
`parcel_id` int(11) NULL DEFAULT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of parcel_items
-- ----------------------------
INSERT INTO `parcel_items` VALUES (1, 1, '新品');
INSERT INTO `parcel_items` VALUES (2, 3, '新品2');
-- ----------------------------
-- Table structure for parcels
-- ----------------------------
DROP TABLE IF EXISTS `parcels`;
CREATE TABLE `parcels` (
`id` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of parcels
-- ----------------------------
INSERT INTO `parcels` VALUES (1);
INSERT INTO `parcels` VALUES (2);
INSERT INTO `parcels` VALUES (3);

View File

@ -1,30 +0,0 @@
-- ----------------------------
-- Table structure for items
-- ----------------------------
CREATE TABLE `items` (
`id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of items
-- ----------------------------
INSERT INTO `items` VALUES (1, '金秋产品1');
INSERT INTO `items` VALUES (2, '金秋产品2');
-- ----------------------------
-- Table structure for parcels
-- ----------------------------
CREATE TABLE `parcels` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`item_id` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of parcels
-- ----------------------------
INSERT INTO `parcels` VALUES (1, 1);
INSERT INTO `parcels` VALUES (2, 2);
INSERT INTO `parcels` VALUES (3, 0);

View File

@ -1,9 +0,0 @@
CREATE TABLE `issue2105` (
`id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO `issue2105` VALUES ('1', NULL);
INSERT INTO `issue2105` VALUES ('2', '[{\"Name\": \"任务类型\", \"Value\": \"高价值\"}, {\"Name\": \"优先级\", \"Value\": \"高\"}, {\"Name\": \"是否亮点功能\", \"Value\": \"是\"}]');

View File

@ -1,47 +0,0 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(0) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '||s',
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '角色名称||s,r',
`code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '角色 code||s,r',
`description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '描述信息|text',
`weight` int(0) UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序||r|min:0#发布状态不能小于 0',
`status_id` int(0) UNSIGNED NOT NULL DEFAULT 1 COMMENT '发布状态|hasOne|f:status,fk:id',
`created_at` datetime(0) NULL DEFAULT NULL,
`updated_at` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `code`(`code`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1091 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '系统角色表' ROW_FORMAT = Compact;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, '开发人员', 'developer', '123123', 900, 2, '2022-09-03 21:25:03', '2022-09-09 23:35:23');
INSERT INTO `sys_role` VALUES (2, '管理员', 'admin', '', 800, 1, '2022-09-03 21:25:03', '2022-09-09 23:00:17');
INSERT INTO `sys_role` VALUES (3, '运营', 'operator', '', 700, 1, '2022-09-03 21:25:03', '2022-09-03 21:25:03');
INSERT INTO `sys_role` VALUES (4, '客服', 'service', '', 600, 1, '2022-09-03 21:25:03', '2022-09-03 21:25:03');
INSERT INTO `sys_role` VALUES (5, '收银', 'account', '', 500, 1, '2022-09-03 21:25:03', '2022-09-03 21:25:03');
-- ----------------------------
-- Table structure for sys_status
-- ----------------------------
DROP TABLE IF EXISTS `sys_status`;
CREATE TABLE `sys_status` (
`id` int(0) UNSIGNED NOT NULL AUTO_INCREMENT,
`en` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '英文名称',
`cn` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中文名称',
`weight` int(0) UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序权重',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '发布状态' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_status
-- ----------------------------
INSERT INTO `sys_status` VALUES (1, 'on line', '上线', 900);
INSERT INTO `sys_status` VALUES (2, 'undecided', '未决定', 800);
INSERT INTO `sys_status` VALUES (3, 'off line', '下线', 700);

View File

@ -1,19 +0,0 @@
CREATE TABLE `a` (
`id` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB;
INSERT INTO `a` (`id`) VALUES ('2');
CREATE TABLE `b` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB;
INSERT INTO `b` (`id`, `name`) VALUES ('2', 'a');
INSERT INTO `b` (`id`, `name`) VALUES ('3', 'b');
CREATE TABLE `c` (
`id` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB;
INSERT INTO `c` (`id`) VALUES ('2');

View File

@ -1,7 +0,0 @@
CREATE TABLE `issue2643` (
`id` INT(10) NULL DEFAULT NULL,
`name` VARCHAR(50) NULL DEFAULT NULL,
`value` INT(10) NULL DEFAULT NULL,
`dept` VARCHAR(50) NULL DEFAULT NULL
)
ENGINE=InnoDB

View File

@ -1,10 +0,0 @@
CREATE TABLE `issue3086_user`
(
`id` int(10) unsigned NOT NULL COMMENT 'User ID',
`passport` varchar(45) NOT NULL COMMENT 'User Passport',
`password` varchar(45) DEFAULT NULL COMMENT 'User Password',
`nickname` varchar(45) DEFAULT NULL COMMENT 'User Nickname',
`create_at` datetime DEFAULT NULL COMMENT 'Created Time',
`update_at` datetime DEFAULT NULL COMMENT 'Updated Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@ -1,14 +0,0 @@
CREATE TABLE `issue3218_sys_config` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '配置名称',
`value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '配置值',
`created_at` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`updated_at` timestamp NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name`(191)) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of sys_config
-- ----------------------------
INSERT INTO `issue3218_sys_config` VALUES (49, 'site', '{\"banned_ip\":\"22\",\"filings\":\"2222\",\"fixed_page\":\"\",\"site_name\":\"22\",\"version\":\"22\"}', '2023-12-19 14:08:25', '2023-12-19 14:08:25');

View File

@ -1,5 +0,0 @@
CREATE TABLE `issue3626` (
id int(11) NOT NULL,
name varchar(45) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@ -1,8 +0,0 @@
CREATE TABLE `issue3754` (
id int(11) NOT NULL,
name varchar(45) DEFAULT NULL,
create_at datetime(0) DEFAULT NULL,
update_at datetime(0) DEFAULT NULL,
delete_at datetime(0) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@ -1,9 +0,0 @@
CREATE TABLE `issue3915` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'user id',
`a` float DEFAULT NULL COMMENT 'user name',
`b` float DEFAULT NULL COMMENT 'user status',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `issue3915` (`id`,`a`,`b`) VALUES (1,1,2);
INSERT INTO `issue3915` (`id`,`a`,`b`) VALUES (2,5,4);

View File

@ -1,8 +0,0 @@
CREATE TABLE issue4034 (
id INT PRIMARY KEY AUTO_INCREMENT,
passport VARCHAR(255),
password VARCHAR(255),
nickname VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

View File

@ -1,10 +0,0 @@
DROP TABLE IF EXISTS `issue4086`;
CREATE TABLE `issue4086` (
`proxy_id` bigint NOT NULL,
`recommend_ids` json DEFAULT NULL,
`photos` json DEFAULT NULL,
PRIMARY KEY (`proxy_id`)
) ENGINE=InnoDB;
INSERT INTO `issue4086` (`proxy_id`, `recommend_ids`, `photos`) VALUES (1, '[584, 585]', 'null');
INSERT INTO `issue4086` (`proxy_id`, `recommend_ids`, `photos`) VALUES (2, '[]', NULL);

View File

@ -1,20 +0,0 @@
CREATE TABLE %s (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`key` varchar(45) DEFAULT NULL,
`category_id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned NOT NULL,
`title` varchar(255) NOT NULL,
`content` mediumtext NOT NULL,
`sort` int(10) unsigned DEFAULT '0',
`brief` varchar(255) DEFAULT NULL,
`thumb` varchar(255) DEFAULT NULL,
`tags` varchar(900) DEFAULT NULL,
`referer` varchar(255) DEFAULT NULL,
`status` smallint(5) unsigned DEFAULT '0',
`view_count` int(10) unsigned DEFAULT '0',
`zan_count` int(10) unsigned DEFAULT NULL,
`cai_count` int(10) unsigned DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@ -1570,7 +1570,6 @@ func Test_DB_Ctx(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := db.Query(ctx, "SELECT SLEEP(10)")
t.AssertNE(err, nil)
t.Assert(gstr.Contains(err.Error(), "deadline"), true)
})
}

View File

@ -58,7 +58,7 @@ func init() {
nodePrefix.Prefix = TableNamePrefix1
nodeInvalid := gdb.ConfigNode{
Link: fmt.Sprintf("mysql:root:%s@tcp(127.0.0.1:3316)/?loc=Local&parseTime=true", TestDbPass),
Link: fmt.Sprintf("mysql:root:%s@tcp(127.0.0.1:3307)/?loc=Local&parseTime=true", TestDbPass),
TranTimeout: time.Second * 3,
}
gdb.AddConfigNode("test", nodeDefault)

View File

@ -864,6 +864,24 @@ const (
fieldTypeTimestampz = "timestamptz"
fieldTypeJson = "json"
fieldTypeJsonb = "jsonb"
// PostgreSQL specific types.
fieldTypeInt2 = "int2"
fieldTypeInt4 = "int4"
fieldTypeInteger = "integer"
fieldTypeInt8 = "int8"
fieldTypeFloat4 = "float4"
fieldTypeFloat8 = "float8"
fieldTypeDoublePrecision = "double precision"
fieldTypeBoolean = "boolean"
// Oracle specific types.
fieldTypeNumber = "number"
// MSSQL specific types.
fieldTypeDatetime2 = "datetime2"
fieldTypeDatetimeOffset = "datetimeoffset"
fieldTypeSmalldatetime = "smalldatetime"
)
var (

View File

@ -30,120 +30,120 @@ type ConfigGroup []ConfigNode
type ConfigNode struct {
// Host specifies the server address, can be either IP address or domain name
// Example: "127.0.0.1", "localhost"
Host string `json:"host" d:"127.0.0.1" dc:"Database server address|i18n:config.database.host"`
Host string `json:"host"`
// Port specifies the server port number
// Default is typically "3306" for MySQL
Port string `json:"port" dc:"Database server port|i18n:config.database.port"`
Port string `json:"port"`
// User specifies the authentication username for database connection
User string `json:"user" v:"required" dc:"Database username|i18n:config.database.user"`
User string `json:"user"`
// Pass specifies the authentication password for database connection
Pass string `json:"pass" dc:"Database password|i18n:config.database.pass"`
Pass string `json:"pass"`
// Name specifies the default database name to be used
Name string `json:"name" v:"required" dc:"Database name|i18n:config.database.name"`
Name string `json:"name"`
// Type specifies the database type
// Example: mysql, mariadb, sqlite, mssql, pgsql, oracle, clickhouse, dm.
Type string `json:"type" v:"required" dc:"Database type (mysql,pgsql,sqlite,mssql,oracle,clickhouse,dm)|i18n:config.database.type"`
Type string `json:"type"`
// Link provides custom connection string that combines all configuration in one string
// Optional field
Link string `json:"link" dc:"Custom connection string|i18n:config.database.link"`
Link string `json:"link"`
// Extra provides additional configuration options for third-party database drivers
// Optional field
Extra string `json:"extra" dc:"Extra connection options|i18n:config.database.extra"`
Extra string `json:"extra"`
// Role specifies the node role in master-slave setup
// Optional field, defaults to "master"
// Available values: "master", "slave"
Role Role `json:"role" d:"master" dc:"Node role (master/slave)|i18n:config.database.role"`
Role Role `json:"role"`
// Debug enables debug mode for logging and output
// Optional field
Debug bool `json:"debug" d:"false" dc:"Enable debug mode|i18n:config.database.debug"`
Debug bool `json:"debug"`
// Prefix specifies the table name prefix
// Optional field
Prefix string `json:"prefix" dc:"Table name prefix|i18n:config.database.prefix"`
Prefix string `json:"prefix"`
// DryRun enables simulation mode where SELECT statements are executed
// but INSERT/UPDATE/DELETE statements are not
// Optional field
DryRun bool `json:"dryRun" d:"false" dc:"Enable dry run mode|i18n:config.database.dryRun"`
DryRun bool `json:"dryRun"`
// Weight specifies the node weight for load balancing calculations
// Optional field, only effective in multi-node setups
Weight int `json:"weight" d:"0" dc:"Node weight for load balancing|i18n:config.database.weight"`
Weight int `json:"weight"`
// Charset specifies the character set for database operations
// Optional field, defaults to "utf8"
Charset string `json:"charset" d:"utf8" dc:"Character set|i18n:config.database.charset"`
Charset string `json:"charset"`
// Protocol specifies the network protocol for database connection
// Optional field, defaults to "tcp"
// See net.Dial for available network protocols
Protocol string `json:"protocol" d:"tcp" dc:"Network protocol|i18n:config.database.protocol"`
Protocol string `json:"protocol"`
// Timezone sets the time zone for timestamp interpretation and display
// Optional field
Timezone string `json:"timezone" dc:"Connection timezone|i18n:config.database.timezone"`
Timezone string `json:"timezone"`
// Namespace specifies the schema namespace for certain databases
// Optional field, e.g., in PostgreSQL, Name is the catalog and Namespace is the schema
Namespace string `json:"namespace" dc:"Schema namespace|i18n:config.database.namespace"`
Namespace string `json:"namespace"`
// MaxIdleConnCount specifies the maximum number of idle connections in the pool
// Optional field
MaxIdleConnCount int `json:"maxIdle" d:"10" dc:"Max idle connections|i18n:config.database.maxIdle"`
MaxIdleConnCount int `json:"maxIdle"`
// MaxOpenConnCount specifies the maximum number of open connections in the pool
// Optional field
MaxOpenConnCount int `json:"maxOpen" d:"0" dc:"Max open connections (0=unlimited)|i18n:config.database.maxOpen"`
MaxOpenConnCount int `json:"maxOpen"`
// MaxConnLifeTime specifies the maximum lifetime of a connection
// Optional field
MaxConnLifeTime time.Duration `json:"maxLifeTime" d:"30s" dc:"Max connection lifetime|i18n:config.database.maxLifeTime"`
MaxConnLifeTime time.Duration `json:"maxLifeTime"`
// MaxIdleConnTime specifies the maximum idle time of a connection before being closed
// This is Go 1.15+ feature: sql.DB.SetConnMaxIdleTime
// Optional field
MaxIdleConnTime time.Duration `json:"maxIdleTime" dc:"Max connection idle time|i18n:config.database.maxIdleTime"`
MaxIdleConnTime time.Duration `json:"maxIdleTime"`
// QueryTimeout specifies the maximum execution time for DQL operations
// Optional field
QueryTimeout time.Duration `json:"queryTimeout" dc:"DQL query timeout|i18n:config.database.queryTimeout"`
QueryTimeout time.Duration `json:"queryTimeout"`
// ExecTimeout specifies the maximum execution time for DML operations
// Optional field
ExecTimeout time.Duration `json:"execTimeout" dc:"DML exec timeout|i18n:config.database.execTimeout"`
ExecTimeout time.Duration `json:"execTimeout"`
// TranTimeout specifies the maximum execution time for a transaction block
// Optional field
TranTimeout time.Duration `json:"tranTimeout" dc:"Transaction timeout|i18n:config.database.tranTimeout"`
TranTimeout time.Duration `json:"tranTimeout"`
// PrepareTimeout specifies the maximum execution time for prepare operations
// Optional field
PrepareTimeout time.Duration `json:"prepareTimeout" dc:"Prepare statement timeout|i18n:config.database.prepareTimeout"`
PrepareTimeout time.Duration `json:"prepareTimeout"`
// CreatedAt specifies the field name for automatic timestamp on record creation
// Optional field
CreatedAt string `json:"createdAt" dc:"Auto timestamp field for creation|i18n:config.database.createdAt"`
CreatedAt string `json:"createdAt"`
// UpdatedAt specifies the field name for automatic timestamp on record updates
// Optional field
UpdatedAt string `json:"updatedAt" dc:"Auto timestamp field for update|i18n:config.database.updatedAt"`
UpdatedAt string `json:"updatedAt"`
// DeletedAt specifies the field name for automatic timestamp on record deletion
// Optional field
DeletedAt string `json:"deletedAt" dc:"Auto timestamp field for soft delete|i18n:config.database.deletedAt"`
DeletedAt string `json:"deletedAt"`
// TimeMaintainDisabled controls whether automatic time maintenance is disabled
// Optional field
TimeMaintainDisabled bool `json:"timeMaintainDisabled" d:"false" dc:"Disable auto time maintenance|i18n:config.database.timeMaintainDisabled"`
TimeMaintainDisabled bool `json:"timeMaintainDisabled"`
}
type Role string

View File

@ -226,6 +226,13 @@ Default:
// GetFormattedDBTypeNameForField retrieves and returns the formatted database type name
// eg. `int(10) unsigned` -> `int`, `varchar(100)` -> `varchar`, etc.
func (c *Core) GetFormattedDBTypeNameForField(fieldType string) (typeName, typePattern string) {
return FormatDBTypeName(fieldType)
}
// FormatDBTypeName retrieves and returns the formatted database type name and pattern
// from raw field type string without requiring a database connection.
// eg. `int(10) unsigned` -> (`int`, `10`), `varchar(100)` -> (`varchar`, `100`).
func FormatDBTypeName(fieldType string) (typeName, typePattern string) {
match, _ := gregex.MatchString(`(.+?)\((.+)\)`, fieldType)
if len(match) == 3 {
typeName = gstr.Trim(match[1])
@ -246,11 +253,17 @@ func (c *Core) GetFormattedDBTypeNameForField(fieldType string) (typeName, typeP
// The `fieldType` is retrieved from ColumnTypes of db driver, example:
// UNSIGNED INT
func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ any) (LocalType, error) {
return CheckLocalTypeForFieldType(fieldType)
}
// CheckLocalTypeForFieldType checks and returns corresponding local type for given db field type string
// without requiring a database connection.
func CheckLocalTypeForFieldType(fieldType string) (LocalType, error) {
var (
typeName string
typePattern string
)
typeName, typePattern = c.GetFormattedDBTypeNameForField(fieldType)
typeName, typePattern = FormatDBTypeName(fieldType)
switch typeName {
case
fieldTypeBinary,
@ -268,7 +281,10 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
fieldTypeSmallint,
fieldTypeMediumInt,
fieldTypeMediumint,
fieldTypeSerial:
fieldTypeSerial,
fieldTypeInt2,
fieldTypeInt4,
fieldTypeInteger:
if gstr.ContainsI(fieldType, "unsigned") {
return LocalTypeUint, nil
}
@ -277,7 +293,8 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
case
fieldTypeBigInt,
fieldTypeBigint,
fieldTypeBigserial:
fieldTypeBigserial,
fieldTypeInt8:
if gstr.ContainsI(fieldType, "unsigned") {
return LocalTypeUint64, nil
}
@ -298,11 +315,15 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
fieldTypeDecimal,
fieldTypeMoney,
fieldTypeNumeric,
fieldTypeSmallmoney:
fieldTypeSmallmoney,
fieldTypeNumber:
return LocalTypeString, nil
case
fieldTypeFloat,
fieldTypeDouble:
fieldTypeDouble,
fieldTypeFloat4,
fieldTypeFloat8,
fieldTypeDoublePrecision:
return LocalTypeFloat64, nil
case
@ -317,7 +338,8 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
return LocalTypeInt64Bytes, nil
case
fieldTypeBool:
fieldTypeBool,
fieldTypeBoolean:
return LocalTypeBool, nil
case
@ -331,7 +353,10 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
case
fieldTypeDatetime,
fieldTypeTimestamp,
fieldTypeTimestampz:
fieldTypeTimestampz,
fieldTypeDatetime2,
fieldTypeDatetimeOffset,
fieldTypeSmalldatetime:
return LocalTypeDatetime, nil
case
@ -345,7 +370,10 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
default:
// Auto-detect field type, using key match.
switch {
case strings.Contains(typeName, "text") || strings.Contains(typeName, "char") || strings.Contains(typeName, "character"):
case strings.Contains(typeName, "text") || strings.Contains(typeName, "char") ||
strings.Contains(typeName, "character") || strings.Contains(typeName, "clob") ||
strings.Contains(typeName, "ntext") || strings.Contains(typeName, "xml") ||
strings.Contains(typeName, "string"):
return LocalTypeString, nil
case strings.Contains(typeName, "float") || strings.Contains(typeName, "double") || strings.Contains(typeName, "numeric"):
@ -354,7 +382,9 @@ func (c *Core) CheckLocalTypeForField(ctx context.Context, fieldType string, _ a
case strings.Contains(typeName, "bool"):
return LocalTypeBool, nil
case strings.Contains(typeName, "binary") || strings.Contains(typeName, "blob"):
case strings.Contains(typeName, "binary") || strings.Contains(typeName, "blob") ||
strings.Contains(typeName, "bytea") || strings.Contains(typeName, "image") ||
strings.Contains(typeName, "raw"):
return LocalTypeBytes, nil
case strings.Contains(typeName, "int"):

View File

@ -37,12 +37,6 @@ func (m *Model) TableFields(tableStr string, schema ...string) (fields map[strin
usedTable = m.db.GetCore().guessPrimaryTableName(tableStr)
usedSchema = gutil.GetOrDefaultStr(m.schema, schema...)
)
// Strip quote characters from schema name, as it may come from cross-database
// table parsing (e.g., `schema`.`table`) and contain database-specific quote chars.
charL, charR := m.db.GetChars()
if usedSchema != "" && (charL != "" || charR != "") {
usedSchema = gstr.Trim(usedSchema, charL+charR)
}
// Sharding feature.
usedSchema, err = m.getActualSchema(ctx, usedSchema)
if err != nil {

View File

@ -21,28 +21,28 @@ import (
// Config is redis configuration.
type Config struct {
// Address It supports single and cluster redis server. Multiple addresses joined with char ','. Eg: 192.168.1.1:6379, 192.168.1.2:6379.
Address string `json:"address" v:"required" dc:"Redis server address|i18n:config.redis.address"`
Db int `json:"db" d:"0" dc:"Redis database index|i18n:config.redis.db"` // Redis db.
User string `json:"user" dc:"Username for AUTH|i18n:config.redis.user"` // Username for AUTH.
Pass string `json:"pass" dc:"Password for AUTH|i18n:config.redis.pass"` // Password for AUTH.
SentinelUser string `json:"sentinel_user" dc:"Username for sentinel AUTH|i18n:config.redis.sentinelUser"` // Username for sentinel AUTH.
SentinelPass string `json:"sentinel_pass" dc:"Password for sentinel AUTH|i18n:config.redis.sentinelPass"` // Password for sentinel AUTH.
MinIdle int `json:"minIdle" d:"0" dc:"Min idle connections|i18n:config.redis.minIdle"` // Minimum number of connections allowed to be idle (default is 0)
MaxIdle int `json:"maxIdle" d:"10" dc:"Max idle connections|i18n:config.redis.maxIdle"` // Maximum number of connections allowed to be idle (default is 10)
MaxActive int `json:"maxActive" d:"0" dc:"Max active connections (0=unlimited)|i18n:config.redis.maxActive"` // Maximum number of connections limit (default is 0 means no limit).
MaxConnLifetime time.Duration `json:"maxConnLifetime" d:"30s" dc:"Max connection lifetime|i18n:config.redis.maxConnLifetime"` // Maximum lifetime of the connection (default is 30 seconds, not allowed to be set to 0)
IdleTimeout time.Duration `json:"idleTimeout" d:"10s" dc:"Idle connection timeout|i18n:config.redis.idleTimeout"` // Maximum idle time for connection (default is 10 seconds, not allowed to be set to 0)
WaitTimeout time.Duration `json:"waitTimeout" dc:"Wait timeout for connection pool|i18n:config.redis.waitTimeout"` // Timed out duration waiting to get a connection from the connection pool.
DialTimeout time.Duration `json:"dialTimeout" dc:"Dial connection timeout|i18n:config.redis.dialTimeout"` // Dial connection timeout for TCP.
ReadTimeout time.Duration `json:"readTimeout" dc:"Read timeout|i18n:config.redis.readTimeout"` // Read timeout for TCP. DO NOT set it if not necessary.
WriteTimeout time.Duration `json:"writeTimeout" dc:"Write timeout|i18n:config.redis.writeTimeout"` // Write timeout for TCP.
MasterName string `json:"masterName" dc:"Master name for Sentinel mode|i18n:config.redis.masterName"` // Used in Redis Sentinel mode.
TLS bool `json:"tls" d:"false" dc:"Enable TLS connection|i18n:config.redis.tls"` // Specifies whether TLS should be used when connecting to the server.
TLSSkipVerify bool `json:"tlsSkipVerify" d:"false" dc:"Skip TLS server name verification|i18n:config.redis.tlsSkipVerify"` // Disables server name verification when connecting over TLS.
TLSConfig *tls.Config `json:"-"` // TLS Config to use. When set TLS will be negotiated.
SlaveOnly bool `json:"slaveOnly" d:"false" dc:"Route commands to slave nodes only|i18n:config.redis.slaveOnly"` // Route all commands to slave read-only nodes.
Cluster bool `json:"cluster" d:"false" dc:"Enable cluster mode|i18n:config.redis.cluster"` // Specifies whether cluster mode be used.
Protocol int `json:"protocol" d:"3" dc:"RESP protocol version (2 or 3)|i18n:config.redis.protocol"` // Specifies the RESP version (Protocol 2 or 3.)
Address string `json:"address"`
Db int `json:"db"` // Redis db.
User string `json:"user"` // Username for AUTH.
Pass string `json:"pass"` // Password for AUTH.
SentinelUser string `json:"sentinel_user"` // Username for sentinel AUTH.
SentinelPass string `json:"sentinel_pass"` // Password for sentinel AUTH.
MinIdle int `json:"minIdle"` // Minimum number of connections allowed to be idle (default is 0)
MaxIdle int `json:"maxIdle"` // Maximum number of connections allowed to be idle (default is 10)
MaxActive int `json:"maxActive"` // Maximum number of connections limit (default is 0 means no limit).
MaxConnLifetime time.Duration `json:"maxConnLifetime"` // Maximum lifetime of the connection (default is 30 seconds, not allowed to be set to 0)
IdleTimeout time.Duration `json:"idleTimeout"` // Maximum idle time for connection (default is 10 seconds, not allowed to be set to 0)
WaitTimeout time.Duration `json:"waitTimeout"` // Timed out duration waiting to get a connection from the connection pool.
DialTimeout time.Duration `json:"dialTimeout"` // Dial connection timeout for TCP.
ReadTimeout time.Duration `json:"readTimeout"` // Read timeout for TCP. DO NOT set it if not necessary.
WriteTimeout time.Duration `json:"writeTimeout"` // Write timeout for TCP.
MasterName string `json:"masterName"` // Used in Redis Sentinel mode.
TLS bool `json:"tls"` // Specifies whether TLS should be used when connecting to the server.
TLSSkipVerify bool `json:"tlsSkipVerify"` // Disables server name verification when connecting over TLS.
TLSConfig *tls.Config `json:"-"` // TLS Config to use. When set TLS will be negotiated.
SlaveOnly bool `json:"slaveOnly"` // Route all commands to slave read-only nodes.
Cluster bool `json:"cluster"` // Specifies whether cluster mode be used.
Protocol int `json:"protocol"` // Specifies the RESP version (Protocol 2 or 3.)
}
const (

View File

@ -32,9 +32,6 @@ func init() {
if goRootForFilter != "" {
goRootForFilter = strings.ReplaceAll(goRootForFilter, "\\", "/")
}
if len(os.Args) == 0 {
return
}
// Initialize internal package variable: selfPath.
selfPath, _ = exec.LookPath(os.Args[0])
if selfPath != "" {

View File

@ -8,6 +8,7 @@ package ghttp
import (
"context"
"os"
"strings"
"time"
@ -58,11 +59,7 @@ func (p *utilAdmin) Restart(r *Request) {
// Custom start binary path when this process exits.
path := r.GetQuery("newExeFilePath").String()
if path == "" {
path = gfile.SelfPath()
}
if path == "" {
r.Response.WriteExit("cannot determine current executable path")
return
path = os.Args[0]
}
if err = RestartAllServer(ctx, path); err == nil {
r.Response.WriteExit("server restarted")

View File

@ -119,25 +119,16 @@ func checkActionFrequency() error {
// forkReloadProcess creates a new child process and copies the fd to child process.
func forkReloadProcess(ctx context.Context, newExeFilePath ...string) error {
var (
binaryPath = gfile.SelfPath()
binaryPath = os.Args[0]
)
if len(newExeFilePath) > 0 && newExeFilePath[0] != "" {
binaryPath = newExeFilePath[0]
}
if binaryPath == "" {
return gerror.NewCodef(
gcode.CodeInvalidOperation,
"cannot determine current executable path: gfile.SelfPath() returned empty and no executable override was provided (goos=%s, goarch=%s, overrideProvided=%t)",
runtime.GOOS,
runtime.GOARCH,
len(newExeFilePath) > 0 && newExeFilePath[0] != "",
)
}
if !gfile.Exists(binaryPath) {
return gerror.Newf(`binary file path "%s" does not exist`, binaryPath)
}
var (
p = gproc.NewProcess(binaryPath, getCurrentProcessArgs(), os.Environ())
p = gproc.NewProcess(binaryPath, os.Args[1:], os.Environ())
sfm = getServerFdMap()
)
for name, m := range sfm {
@ -174,20 +165,17 @@ func forkReloadProcess(ctx context.Context, newExeFilePath ...string) error {
// forkRestartProcess creates a new server process.
func forkRestartProcess(ctx context.Context, newExeFilePath ...string) error {
var (
path = gfile.SelfPath()
path = os.Args[0]
)
if len(newExeFilePath) > 0 && newExeFilePath[0] != "" {
path = newExeFilePath[0]
}
if path == "" {
return gerror.NewCode(gcode.CodeInvalidOperation, "cannot determine current executable path")
}
if err := os.Unsetenv(adminActionReloadEnvKey); err != nil {
intlog.Errorf(ctx, `%+v`, err)
}
env := os.Environ()
env = append(env, adminActionRestartEnvKey+"=1")
p := gproc.NewProcess(path, getCurrentProcessArgs(), env)
p := gproc.NewProcess(path, os.Args[1:], env)
if _, err := p.Start(ctx); err != nil {
glog.Errorf(
ctx,
@ -199,13 +187,6 @@ func forkRestartProcess(ctx context.Context, newExeFilePath ...string) error {
return nil
}
func getCurrentProcessArgs() []string {
if len(os.Args) > 1 {
return os.Args[1:]
}
return nil
}
// getServerFdMap returns all the servers name to file descriptor mapping as map.
func getServerFdMap() map[string]listenerFdMap {
sfm := make(map[string]listenerFdMap)

View File

@ -50,26 +50,26 @@ type ServerConfig struct {
// ======================================================================================================
// Service name, which is for service registry and discovery.
Name string `json:"name" d:"default" dc:"Service name for registry and discovery|i18n:config.server.name"`
Name string `json:"name"`
// Address specifies the server listening address like "port" or ":port",
// multiple addresses joined using ','.
Address string `json:"address" d:":0" v:"required" dc:"Server listening address|i18n:config.server.address"`
Address string `json:"address"`
// HTTPSAddr specifies the HTTPS addresses, multiple addresses joined using char ','.
HTTPSAddr string `json:"httpsAddr" dc:"HTTPS listening address|i18n:config.server.httpsAddr"`
HTTPSAddr string `json:"httpsAddr"`
// Listeners specifies the custom listeners.
Listeners []net.Listener `json:"listeners"`
// Endpoints are custom endpoints for service register, it uses Address if empty.
Endpoints []string `json:"endpoints" dc:"Custom endpoints for service register|i18n:config.server.endpoints"`
Endpoints []string `json:"endpoints"`
// HTTPSCertPath specifies certification file path for HTTPS service.
HTTPSCertPath string `json:"httpsCertPath" dc:"HTTPS certification file path|i18n:config.server.httpsCertPath"`
HTTPSCertPath string `json:"httpsCertPath"`
// HTTPSKeyPath specifies the key file path for HTTPS service.
HTTPSKeyPath string `json:"httpsKeyPath" dc:"HTTPS key file path|i18n:config.server.httpsKeyPath"`
HTTPSKeyPath string `json:"httpsKeyPath"`
// TLSConfig optionally provides a TLS configuration for use
// by ServeTLS and ListenAndServeTLS. Note that this value is
@ -90,19 +90,19 @@ type ServerConfig struct {
// decisions on each request body's acceptable deadline or
// upload rate, most users will prefer to use
// ReadHeaderTimeout. It is valid to use them both.
ReadTimeout time.Duration `json:"readTimeout" d:"60s" dc:"HTTP read timeout duration|i18n:config.server.readTimeout"`
ReadTimeout time.Duration `json:"readTimeout"`
// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
WriteTimeout time.Duration `json:"writeTimeout" d:"0" dc:"HTTP write timeout duration|i18n:config.server.writeTimeout"`
WriteTimeout time.Duration `json:"writeTimeout"`
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alive are enabled. If IdleTimeout
// is zero, the value of ReadTimeout is used. If both are
// zero, there is no timeout.
IdleTimeout time.Duration `json:"idleTimeout" d:"60s" dc:"HTTP idle timeout duration|i18n:config.server.idleTimeout"`
IdleTimeout time.Duration `json:"idleTimeout"`
// MaxHeaderBytes controls the maximum number of bytes the
// server will read parsing the request header's keys and
@ -111,14 +111,14 @@ type ServerConfig struct {
//
// It can be configured in configuration file using string like: 1m, 10m, 500kb etc.
// It's 10240 bytes in default.
MaxHeaderBytes int `json:"maxHeaderBytes" d:"10240" dc:"Max header size in bytes|i18n:config.server.maxHeaderBytes"`
MaxHeaderBytes int `json:"maxHeaderBytes"`
// KeepAlive enables HTTP keep-alive.
KeepAlive bool `json:"keepAlive" d:"true" dc:"Enable HTTP keep-alive|i18n:config.server.keepAlive"`
KeepAlive bool `json:"keepAlive"`
// ServerAgent specifies the server agent information, which is wrote to
// HTTP response header as "Server".
ServerAgent string `json:"serverAgent" d:"GoFrame HTTP Server" dc:"Server agent header value|i18n:config.server.serverAgent"`
ServerAgent string `json:"serverAgent"`
// View specifies the default template view object for the server.
View *gview.View `json:"view"`
@ -128,78 +128,78 @@ type ServerConfig struct {
// ======================================================================================================
// Rewrites specifies the URI rewrite rules map.
Rewrites map[string]string `json:"rewrites" dc:"URI rewrite rules map|i18n:config.server.rewrites"`
Rewrites map[string]string `json:"rewrites"`
// IndexFiles specifies the index files for static folder.
IndexFiles []string `json:"indexFiles" dc:"Index files for static folder|i18n:config.server.indexFiles"`
IndexFiles []string `json:"indexFiles"`
// IndexFolder specifies if listing sub-files when requesting folder.
// The server responses HTTP status code 403 if it is false.
IndexFolder bool `json:"indexFolder" d:"false" dc:"Allow listing folder contents|i18n:config.server.indexFolder"`
IndexFolder bool `json:"indexFolder"`
// ServerRoot specifies the root directory for static service.
ServerRoot string `json:"serverRoot" dc:"Root directory for static service|i18n:config.server.serverRoot"`
ServerRoot string `json:"serverRoot"`
// SearchPaths specifies additional searching directories for static service.
SearchPaths []string `json:"searchPaths" dc:"Additional search paths for static service|i18n:config.server.searchPaths"`
SearchPaths []string `json:"searchPaths"`
// StaticPaths specifies URI to directory mapping array.
StaticPaths []staticPathItem `json:"staticPaths"`
// FileServerEnabled is the global switch for static service.
// It is automatically set enabled if any static path is set.
FileServerEnabled bool `json:"fileServerEnabled" d:"false" dc:"Enable static file server|i18n:config.server.fileServerEnabled"`
FileServerEnabled bool `json:"fileServerEnabled"`
// ======================================================================================================
// Cookie.
// ======================================================================================================
// CookieMaxAge specifies the max TTL for cookie items.
CookieMaxAge time.Duration `json:"cookieMaxAge" d:"8760h" dc:"Cookie max TTL duration|i18n:config.server.cookieMaxAge"`
CookieMaxAge time.Duration `json:"cookieMaxAge"`
// CookiePath specifies cookie path.
// It also affects the default storage for session id.
CookiePath string `json:"cookiePath" d:"/" dc:"Cookie path|i18n:config.server.cookiePath"`
CookiePath string `json:"cookiePath"`
// CookieDomain specifies cookie domain.
// It also affects the default storage for session id.
CookieDomain string `json:"cookieDomain" dc:"Cookie domain|i18n:config.server.cookieDomain"`
CookieDomain string `json:"cookieDomain"`
// CookieSameSite specifies cookie SameSite property.
// It also affects the default storage for session id.
CookieSameSite string `json:"cookieSameSite" dc:"Cookie SameSite property|i18n:config.server.cookieSameSite"`
CookieSameSite string `json:"cookieSameSite"`
// CookieSameSite specifies cookie Secure property.
// It also affects the default storage for session id.
CookieSecure bool `json:"cookieSecure" d:"false" dc:"Cookie Secure flag|i18n:config.server.cookieSecure"`
CookieSecure bool `json:"cookieSecure"`
// CookieSameSite specifies cookie HttpOnly property.
// It also affects the default storage for session id.
CookieHttpOnly bool `json:"cookieHttpOnly" d:"false" dc:"Cookie HttpOnly flag|i18n:config.server.cookieHttpOnly"`
CookieHttpOnly bool `json:"cookieHttpOnly"`
// ======================================================================================================
// Session.
// ======================================================================================================
// SessionIdName specifies the session id name.
SessionIdName string `json:"sessionIdName" d:"gfsessionid" dc:"Session ID name|i18n:config.server.sessionIdName"`
SessionIdName string `json:"sessionIdName"`
// SessionMaxAge specifies max TTL for session items.
SessionMaxAge time.Duration `json:"sessionMaxAge" d:"24h" dc:"Session max TTL duration|i18n:config.server.sessionMaxAge"`
SessionMaxAge time.Duration `json:"sessionMaxAge"`
// SessionPath specifies the session storage directory path for storing session files.
// It only makes sense if the session storage is type of file storage.
SessionPath string `json:"sessionPath" dc:"Session file storage path|i18n:config.server.sessionPath"`
SessionPath string `json:"sessionPath"`
// SessionStorage specifies the session storage.
SessionStorage gsession.Storage `json:"sessionStorage"`
// SessionCookieMaxAge specifies the cookie ttl for session id.
// If it is set 0, it means it expires along with browser session.
SessionCookieMaxAge time.Duration `json:"sessionCookieMaxAge" d:"24h" dc:"Session cookie max TTL|i18n:config.server.sessionCookieMaxAge"`
SessionCookieMaxAge time.Duration `json:"sessionCookieMaxAge"`
// SessionCookieOutput specifies whether automatic outputting session id to cookie.
SessionCookieOutput bool `json:"sessionCookieOutput" d:"true" dc:"Auto output session id to cookie|i18n:config.server.sessionCookieOutput"`
SessionCookieOutput bool `json:"sessionCookieOutput"`
// ======================================================================================================
// Logging.
@ -235,13 +235,13 @@ type ServerConfig struct {
// ======================================================================================================
// Graceful enables graceful reload feature for all servers of the process.
Graceful bool `json:"graceful" d:"false" dc:"Enable graceful reload|i18n:config.server.graceful"`
Graceful bool `json:"graceful"`
// GracefulTimeout set the maximum survival time (seconds) of the parent process.
GracefulTimeout int `json:"gracefulTimeout" d:"2" dc:"Graceful reload timeout in seconds|i18n:config.server.gracefulTimeout"`
GracefulTimeout int `json:"gracefulTimeout"`
// GracefulShutdownTimeout set the maximum survival time (seconds) before stopping the server.
GracefulShutdownTimeout int `json:"gracefulShutdownTimeout" d:"5" dc:"Graceful shutdown timeout in seconds|i18n:config.server.gracefulShutdownTimeout"`
GracefulShutdownTimeout int `json:"gracefulShutdownTimeout"`
// ======================================================================================================
// Other.
@ -250,23 +250,23 @@ type ServerConfig struct {
// ClientMaxBodySize specifies the max body size limit in bytes for client request.
// It can be configured in configuration file using string like: 1m, 10m, 500kb etc.
// It's `8MB` in default.
ClientMaxBodySize int64 `json:"clientMaxBodySize" d:"8388608" dc:"Max client body size in bytes|i18n:config.server.clientMaxBodySize"`
ClientMaxBodySize int64 `json:"clientMaxBodySize"`
// FormParsingMemory specifies max memory buffer size in bytes which can be used for
// parsing multimedia form.
// It can be configured in configuration file using string like: 1m, 10m, 500kb etc.
// It's 1MB in default.
FormParsingMemory int64 `json:"formParsingMemory" d:"1048576" dc:"Max form parsing memory in bytes|i18n:config.server.formParsingMemory"`
FormParsingMemory int64 `json:"formParsingMemory"`
// NameToUriType specifies the type for converting struct method name to URI when
// registering routes.
NameToUriType int `json:"nameToUriType" d:"0" dc:"Method name to URI type (0:default,1:fullname,2:alllower,3:camel)|i18n:config.server.nameToUriType"`
NameToUriType int `json:"nameToUriType"`
// RouteOverWrite allows to overwrite the route if duplicated.
RouteOverWrite bool `json:"routeOverWrite" d:"false" dc:"Allow overwriting duplicate routes|i18n:config.server.routeOverWrite"`
RouteOverWrite bool `json:"routeOverWrite"`
// DumpRouterMap specifies whether automatically dumps router map when server starts.
DumpRouterMap bool `json:"dumpRouterMap" d:"true" dc:"Dump router map on server start|i18n:config.server.dumpRouterMap"`
DumpRouterMap bool `json:"dumpRouterMap"`
}
// NewConfig creates and returns a ServerConfig object with default configurations.

View File

@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-04-24

View File

@ -1,3 +0,0 @@
# add-unit-test-coverage-requirements
Require unit tests for submitted code changes and enforce minimum coverage targets for new code.

View File

@ -1,28 +0,0 @@
## Context
`CLAUDE.md` and `AGENTS.md` currently act as the canonical project rule documents used during implementation and review. The requested change is documentation-only, but the wording must clearly separate the hard minimum quality bar from the preferred target so contributors and reviewers apply the same standard.
## Goals / Non-Goals
**Goals:**
- Add an explicit rule that submitted code changes must include unit tests.
- Add an explicit rule that newly added code must reach at least 80% coverage.
- Preserve a stronger preferred target of 90% or above when feasible.
- Keep the change limited to the canonical rule documents and OpenSpec artifacts.
**Non-Goals:**
- Adding CI automation or coverage tooling in this change.
- Defining repository-wide historical coverage requirements.
- Changing unrelated contribution or coding rules.
## Decisions
- Update both `CLAUDE.md` and `AGENTS.md` so the duplicated project rule documents remain aligned.
- Place the new requirement under `Code Development Rules`, where existing mandatory engineering requirements already live.
- Use 80% as the hard minimum for newly added code and describe 90% or above as the preferred target when feasible.
- Keep the wording focused on unit tests and coverage expectations rather than implementation details about how coverage is measured.
## Risks / Trade-offs
- [Documentation-only enforcement] -> The rule depends on reviewer and contributor discipline until tooling is added; placing it in the canonical rule documents keeps the expectation visible during development and review.
- [Coverage interpretation] -> Different packages may measure coverage differently; using a clear minimum plus a preferred target reduces ambiguity without over-specifying tooling.

View File

@ -1,23 +0,0 @@
## Why
The project rules currently encourage tests, but they do not define a clear mandatory standard for submitted code changes or a minimum coverage threshold for newly added code. Adding an explicit rule makes review expectations consistent and gives contributors a concrete quality bar.
## What Changes
- Add a project rule requiring submitted code changes to include focused unit tests for the introduced or modified behavior.
- Add a project rule requiring newly added code to maintain at least 80% test coverage, with 90% or above treated as the preferred target when feasible.
- Record the requirement in the active OpenSpec change so the new quality gate is traceable.
## Capabilities
### New Capabilities
- `code-quality-gates`: Defines mandatory unit-test and coverage expectations for submitted code changes.
### Modified Capabilities
- None.
## Impact
- `CLAUDE.md`
- `AGENTS.md`
- Review expectations for future code submissions

View File

@ -1,23 +0,0 @@
## ADDED Requirements
### Requirement: Submitted code changes include unit tests
The project SHALL require every submitted code change to include unit tests that directly cover the affected logic and expected behavior of the changed code path in the affected package.
#### Scenario: Behavior-changing code is submitted
- **WHEN** a contribution adds or changes code in the repository
- **THEN** the submission SHALL include unit tests that exercise the affected logic or preserve the expected behavior of the changed code path before the change is considered complete
### Requirement: Newly added code meets the coverage baseline
The project SHALL require the newly added code in a submission to maintain unit-test coverage of at least 80%, and reviews SHALL treat 90% or above as the preferred target when that level is feasible without artificial or low-value tests.
#### Scenario: Coverage falls below the minimum
- **WHEN** the newly added code in a submission is covered below 80%
- **THEN** the change SHALL not satisfy the project quality requirement
#### Scenario: Coverage meets the minimum baseline
- **WHEN** the newly added code in a submission reaches 80% or higher coverage
- **THEN** the change SHALL satisfy the minimum coverage requirement
#### Scenario: Coverage reaches the preferred target
- **WHEN** the newly added code in a submission reaches 90% or higher coverage
- **THEN** the change SHALL satisfy the preferred coverage target for the project

View File

@ -1,8 +0,0 @@
## 1. Document the quality gate
- [x] 1.1 Create the OpenSpec proposal, design, and spec artifacts for the unit-test and coverage requirement
- [x] 1.2 Update the canonical project rule documents with the new unit-test and coverage requirement
## 2. Verify the documentation change
- [x] 2.1 Verify the updated rule documents mention mandatory unit tests and the 80% minimum / 90% preferred coverage targets

View File

@ -1,8 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
context: |
Generate all OpenSpec artifacts in English.

View File

@ -1,27 +0,0 @@
# code-quality-gates Specification
## Purpose
TBD - created by archiving change add-unit-test-coverage-requirements. Update Purpose after archive.
## Requirements
### Requirement: Submitted code changes include unit tests
The project SHALL require every submitted code change to include unit tests that directly cover the affected logic and expected behavior of the changed code path in the affected package.
#### Scenario: Behavior-changing code is submitted
- **WHEN** a contribution adds or changes code in the repository
- **THEN** the submission SHALL include unit tests that exercise the affected logic or preserve the expected behavior of the changed code path before the change is considered complete
### Requirement: Newly added code meets the coverage baseline
The project SHALL require the newly added code in a submission to maintain unit-test coverage of at least 80%, and reviews SHALL treat 90% or above as the preferred target when that level is feasible without artificial or low-value tests.
#### Scenario: Coverage falls below the minimum
- **WHEN** the newly added code in a submission is covered below 80%
- **THEN** the change SHALL not satisfy the project quality requirement
#### Scenario: Coverage meets the minimum baseline
- **WHEN** the newly added code in a submission reaches 80% or higher coverage
- **THEN** the change SHALL satisfy the minimum coverage requirement
#### Scenario: Coverage reaches the preferred target
- **WHEN** the newly added code in a submission reaches 90% or higher coverage
- **THEN** the change SHALL satisfy the preferred coverage target for the project

View File

@ -1,287 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
// Package gcfg provides configuration management functionality for GoFrame.
// This file implements configuration schema registry for visual editing support.
package gcfg
import (
"reflect"
"strings"
"sync"
)
// FieldSchema describes metadata for a single configuration field,
// extracted from struct tags (json, d, v, dc) via reflection.
type FieldSchema struct {
Name string `json:"name"` // Go struct field name
JsonKey string `json:"jsonKey"` // JSON/YAML key from json tag
Type string `json:"type"` // Field type: string, int, bool, duration, etc.
Default string `json:"default"` // Default value from `d` tag
Rule string `json:"rule"` // Validation rule from `v` tag
Description string `json:"description"` // English description from `dc` tag
I18nKey string `json:"i18nKey"` // I18n key extracted from `dc` tag (i18n:xxx)
Group string `json:"group"` // Logical group (Basic, Logging, Cookie, etc.)
Options []string `json:"options,omitempty"` // Enum options if applicable
}
// ModuleSchema describes the configuration schema for one module.
type ModuleSchema struct {
Name string `json:"name"` // Module name: server, database, redis, logger, viewer
ConfigNode string `json:"configNode"` // Config file node name
Fields []*FieldSchema `json:"fields"` // All field schemas
Groups []string `json:"groups"` // Ordered unique group names
}
// SchemaRegistry is the global registry for all module configuration schemas.
// It is thread-safe and supports concurrent registration and retrieval.
type SchemaRegistry struct {
mu sync.RWMutex
schemas map[string]*ModuleSchema
order []string // maintains registration order
}
// globalSchemaRegistry is the package-level global schema registry instance.
var globalSchemaRegistry = NewSchemaRegistry()
// NewSchemaRegistry creates and returns a new SchemaRegistry instance.
func NewSchemaRegistry() *SchemaRegistry {
return &SchemaRegistry{
schemas: make(map[string]*ModuleSchema),
}
}
// RegisterSchema registers a module's configuration struct type to the global registry.
func RegisterSchema(name, configNode string, configStruct any, groupMap map[string]string) {
globalSchemaRegistry.Register(name, configNode, configStruct, groupMap)
}
// GetSchema returns the ModuleSchema for a given module name from the global registry.
func GetSchema(name string) (*ModuleSchema, bool) {
return globalSchemaRegistry.Get(name)
}
// GetAllSchemas returns all registered module schemas from the global registry.
func GetAllSchemas() []*ModuleSchema {
return globalSchemaRegistry.GetAll()
}
// GetGlobalRegistry returns the package-level global schema registry.
func GetGlobalRegistry() *SchemaRegistry {
return globalSchemaRegistry
}
// Register registers a module's configuration schema to this registry.
func (r *SchemaRegistry) Register(name, configNode string, configStruct any, groupMap map[string]string) {
r.mu.Lock()
defer r.mu.Unlock()
fields := scanStructTags(configStruct, groupMap)
groups := extractGroups(fields)
schema := &ModuleSchema{
Name: name,
ConfigNode: configNode,
Fields: fields,
Groups: groups,
}
if _, exists := r.schemas[name]; !exists {
r.order = append(r.order, name)
}
r.schemas[name] = schema
}
// Get returns the ModuleSchema for a given module name.
func (r *SchemaRegistry) Get(name string) (*ModuleSchema, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
schema, ok := r.schemas[name]
return schema, ok
}
// GetAll returns all registered module schemas in registration order.
func (r *SchemaRegistry) GetAll() []*ModuleSchema {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*ModuleSchema, 0, len(r.order))
for _, name := range r.order {
if schema, ok := r.schemas[name]; ok {
result = append(result, schema)
}
}
return result
}
// scanStructTags scans a struct type via reflection and extracts FieldSchema
// from struct tags (json, d, v, dc).
func scanStructTags(configType any, groupMap map[string]string) []*FieldSchema {
t := reflect.TypeOf(configType)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
return scanStructFields(t, groupMap, "")
}
// scanStructFields recursively scans struct fields and returns FieldSchema list.
func scanStructFields(t reflect.Type, groupMap map[string]string, prefix string) []*FieldSchema {
var fields []*FieldSchema
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// Skip unexported fields.
if !field.IsExported() {
continue
}
// Handle embedded structs: recurse into them.
if field.Anonymous {
ft := field.Type
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
fields = append(fields, scanStructFields(ft, groupMap, prefix)...)
}
continue
}
// Skip fields whose type is interface, func, chan, or complex struct.
ft := field.Type
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
switch ft.Kind() {
case reflect.Interface, reflect.Func, reflect.Chan:
continue
case reflect.Struct:
// Allow structs from the "time" package (e.g. time.Time);
// note that time.Duration is int64 and is handled in the Int64 case above.
if ft.PkgPath() != "" && ft.PkgPath() != "time" {
continue
}
}
fs := parseFieldSchema(field, groupMap, prefix)
if fs != nil {
fields = append(fields, fs)
}
}
return fields
}
// parseFieldSchema parses a single struct field into a FieldSchema.
func parseFieldSchema(field reflect.StructField, groupMap map[string]string, prefix string) *FieldSchema {
// Get json key.
jsonKey := ""
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
parts := strings.Split(jsonTag, ",")
if parts[0] != "-" {
jsonKey = parts[0]
} else {
// Skip fields with json:"-"
return nil
}
}
if jsonKey == "" {
jsonKey = lowerFirst(field.Name)
}
if prefix != "" {
jsonKey = prefix + "." + jsonKey
}
typeName := fieldTypeName(field.Type)
defaultVal := field.Tag.Get("d")
rule := field.Tag.Get("v")
description, i18nKey := parseDcTag(field.Tag.Get("dc"))
group := "Other"
if groupMap != nil {
if g, ok := groupMap[field.Name]; ok {
group = g
}
}
return &FieldSchema{
Name: field.Name,
JsonKey: jsonKey,
Type: typeName,
Default: defaultVal,
Rule: rule,
Description: description,
I18nKey: i18nKey,
Group: group,
}
}
// parseDcTag parses the `dc` tag value into description and i18n key.
func parseDcTag(dc string) (description, i18nKey string) {
if dc == "" {
return "", ""
}
parts := strings.SplitN(dc, "|", 2)
description = strings.TrimSpace(parts[0])
if len(parts) > 1 {
suffix := strings.TrimSpace(parts[1])
if strings.HasPrefix(suffix, "i18n:") {
i18nKey = strings.TrimPrefix(suffix, "i18n:")
}
}
return
}
// fieldTypeName returns a human-readable type name for a reflect.Type.
func fieldTypeName(t reflect.Type) string {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Bool:
return "bool"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if t.PkgPath() == "time" && t.Name() == "Duration" {
return "duration"
}
return "int"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "int"
case reflect.Float32, reflect.Float64:
return "float"
case reflect.Slice:
return "[]" + fieldTypeName(t.Elem())
case reflect.Map:
return "map"
default:
return t.String()
}
}
// lowerFirst returns the string with first character lowered.
func lowerFirst(s string) string {
if s == "" {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
// extractGroups returns ordered unique group names from field schemas.
func extractGroups(fields []*FieldSchema) []string {
seen := make(map[string]bool)
var groups []string
for _, f := range fields {
if !seen[f.Group] {
seen[f.Group] = true
groups = append(groups, f.Group)
}
}
return groups
}

View File

@ -1,298 +0,0 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package gcfg_test
import (
"testing"
"time"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/test/gtest"
)
// testServerConfig is a simplified version of ghttp.ServerConfig for testing.
type testServerConfig struct {
Name string `json:"name" d:"default" v:"required" dc:"Server name|i18n:config.server.name"`
Address string `json:"address" d:":0" v:"required" dc:"Server listening address|i18n:config.server.address"`
ReadTimeout time.Duration `json:"readTimeout" d:"60s" dc:"HTTP read timeout|i18n:config.server.readTimeout"`
KeepAlive bool `json:"keepAlive" d:"true" dc:"Enable HTTP keep-alive"`
unexported string // should be skipped
}
// TestBaseConfig tests embedded struct scanning.
type TestBaseConfig struct {
Host string `json:"host" d:"localhost" dc:"Hostname|i18n:config.base.host"`
Port int `json:"port" d:"3306" dc:"Port number"`
}
type TestDatabaseConfig struct {
TestBaseConfig // embedded
User string `json:"user" d:"root" v:"required" dc:"Database user|i18n:config.database.user"`
Password string `json:"password" v:"required" dc:"Database password|i18n:config.database.password"`
}
func TestSchemaRegistry_Register(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
groupMap := map[string]string{
"Name": "Basic",
"Address": "Basic",
"ReadTimeout": "Timeout",
"KeepAlive": "Basic",
}
registry.Register("server", "server", testServerConfig{}, groupMap)
schema, ok := registry.Get("server")
t.Assert(ok, true)
t.AssertNE(schema, nil)
t.Assert(schema.Name, "server")
t.Assert(schema.ConfigNode, "server")
t.Assert(len(schema.Fields) > 0, true)
// Check groups are extracted correctly.
t.Assert(len(schema.Groups), 2) // Basic, Timeout
t.Assert(schema.Groups[0], "Basic")
t.Assert(schema.Groups[1], "Timeout")
})
}
func TestSchemaRegistry_FieldParsing(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
groupMap := map[string]string{
"Name": "Basic",
"Address": "Basic",
"ReadTimeout": "Timeout",
"KeepAlive": "Basic",
}
registry.Register("server", "server", testServerConfig{}, groupMap)
schema, _ := registry.Get("server")
// Find the Name field.
var nameField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "Name" {
nameField = f
break
}
}
t.AssertNE(nameField, nil)
t.Assert(nameField.JsonKey, "name")
t.Assert(nameField.Type, "string")
t.Assert(nameField.Default, "default")
t.Assert(nameField.Rule, "required")
t.Assert(nameField.Description, "Server name")
t.Assert(nameField.I18nKey, "config.server.name")
t.Assert(nameField.Group, "Basic")
// Find the ReadTimeout field (duration type).
var timeoutField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "ReadTimeout" {
timeoutField = f
break
}
}
t.AssertNE(timeoutField, nil)
t.Assert(timeoutField.Type, "duration")
t.Assert(timeoutField.Default, "60s")
t.Assert(timeoutField.Group, "Timeout")
// Find the KeepAlive field (bool type).
var keepAliveField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "KeepAlive" {
keepAliveField = f
break
}
}
t.AssertNE(keepAliveField, nil)
t.Assert(keepAliveField.Type, "bool")
t.Assert(keepAliveField.Default, "true")
// Unexported field should NOT be present.
for _, f := range schema.Fields {
t.AssertNE(f.Name, "unexported")
}
})
}
func TestSchemaRegistry_EmbeddedStruct(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
groupMap := map[string]string{
"Host": "Connection",
"Port": "Connection",
"User": "Auth",
"Password": "Auth",
}
registry.Register("database", "database", TestDatabaseConfig{}, groupMap)
schema, ok := registry.Get("database")
t.Assert(ok, true)
// Should have 4 fields: Host, Port from embedded + User, Password from own fields.
t.Assert(len(schema.Fields), 4)
// Check Host field from embedded struct.
var hostField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "Host" {
hostField = f
break
}
}
t.AssertNE(hostField, nil)
t.Assert(hostField.Default, "localhost")
t.Assert(hostField.I18nKey, "config.base.host")
t.Assert(hostField.Group, "Connection")
})
}
func TestSchemaRegistry_GetAll(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
registry.Register("server", "server", testServerConfig{}, nil)
registry.Register("database", "database", TestDatabaseConfig{}, nil)
all := registry.GetAll()
t.Assert(len(all), 2)
// Registration order is maintained.
t.Assert(all[0].Name, "server")
t.Assert(all[1].Name, "database")
})
}
func TestSchemaRegistry_GetNonExistent(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
schema, ok := registry.Get("nonexistent")
t.Assert(ok, false)
t.Assert(schema, nil)
})
}
func TestSchemaRegistry_GlobalRegistry(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
// Test global functions.
gcfg.RegisterSchema("test_module", "test", testServerConfig{}, map[string]string{
"Name": "Basic",
})
schema, ok := gcfg.GetSchema("test_module")
t.Assert(ok, true)
t.AssertNE(schema, nil)
t.Assert(schema.Name, "test_module")
all := gcfg.GetAllSchemas()
t.Assert(len(all) > 0, true)
// Global registry should be accessible.
reg := gcfg.GetGlobalRegistry()
t.AssertNE(reg, nil)
})
}
func TestSchemaRegistry_DcTagParsing(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
type testConfig struct {
Field1 string `json:"field1" dc:"Some description|i18n:config.test.field1"`
Field2 string `json:"field2" dc:"Just a description"`
Field3 string `json:"field3"`
}
registry.Register("test", "test", testConfig{}, nil)
schema, _ := registry.Get("test")
// Field1: description + i18n.
t.Assert(schema.Fields[0].Description, "Some description")
t.Assert(schema.Fields[0].I18nKey, "config.test.field1")
// Field2: description only.
t.Assert(schema.Fields[1].Description, "Just a description")
t.Assert(schema.Fields[1].I18nKey, "")
// Field3: empty.
t.Assert(schema.Fields[2].Description, "")
t.Assert(schema.Fields[2].I18nKey, "")
})
}
func TestSchemaRegistry_PointerStruct(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
// Register with pointer to struct.
registry.Register("server_ptr", "server", &testServerConfig{}, nil)
schema, ok := registry.Get("server_ptr")
t.Assert(ok, true)
t.Assert(len(schema.Fields) > 0, true)
})
}
func TestSchemaRegistry_MapType(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
type testConfig struct {
Data map[string]any `json:"data" dc:"Map data"`
Tags []string `json:"tags" dc:"Tag list"`
}
registry.Register("maptest", "test", testConfig{}, nil)
schema, _ := registry.Get("maptest")
// Map type.
var dataField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "Data" {
dataField = f
break
}
}
t.AssertNE(dataField, nil)
t.Assert(dataField.Type, "map")
// Slice type.
var tagsField *gcfg.FieldSchema
for _, f := range schema.Fields {
if f.Name == "Tags" {
tagsField = f
break
}
}
t.AssertNE(tagsField, nil)
t.Assert(tagsField.Type, "[]string")
})
}
func TestSchemaRegistry_NilGroupMap(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
registry := gcfg.NewSchemaRegistry()
// Register with nil groupMap — all fields should be "Other".
registry.Register("nogroup", "test", testServerConfig{}, nil)
schema, _ := registry.Get("nogroup")
for _, f := range schema.Fields {
t.Assert(f.Group, "Other")
}
})
}

View File

@ -44,9 +44,6 @@ var (
)
func init() {
if len(os.Args) == 0 {
return
}
// Initialize internal package variable: selfPath.
selfPath, _ = exec.LookPath(os.Args[0])
if selfPath != "" {

View File

@ -23,30 +23,30 @@ import (
// Config is the configuration object for logger.
type Config struct {
Handlers []Handler `json:"-"` // Logger handlers which implement feature similar as middleware.
Writer io.Writer `json:"-"` // Customized io.Writer.
Flags int `json:"flags" d:"20" dc:"Extra flags for logging output|i18n:config.logger.flags"` // Extra flags for logging output features.
TimeFormat string `json:"timeFormat" d:"2006-01-02T15:04:05.000Z07:00" dc:"Logging time format|i18n:config.logger.timeFormat"` // Logging time format
Path string `json:"path" dc:"Logging directory path|i18n:config.logger.path"` // Logging directory path.
File string `json:"file" d:"{Y-m-d}.log" dc:"Log file name pattern|i18n:config.logger.file"` // Format pattern for logging file.
Level int `json:"level" d:"992" dc:"Output level (DEBU=16,INFO=32,NOTI=64,WARN=128,ERRO=256,CRIT=512,ALL=992)|i18n:config.logger.level"` // Output level.
Prefix string `json:"prefix" dc:"Prefix for logging content|i18n:config.logger.prefix"` // Prefix string for every logging content.
StSkip int `json:"stSkip" d:"0" dc:"Stack skip count|i18n:config.logger.stSkip"` // Skipping count for stack.
StStatus int `json:"stStatus" d:"1" dc:"Stack status (1=enabled, 0=disabled)|i18n:config.logger.stStatus"` // Stack status(1: enabled - default; 0: disabled)
StFilter string `json:"stFilter" dc:"Stack string filter|i18n:config.logger.stFilter"` // Stack string filter.
CtxKeys []any `json:"ctxKeys"` // Context keys for logging, which is used for value retrieving from context.
HeaderPrint bool `json:"header" d:"true" dc:"Print log header|i18n:config.logger.headerPrint"` // Print header or not(true in default).
StdoutPrint bool `json:"stdout" d:"true" dc:"Output to stdout|i18n:config.logger.stdoutPrint"` // Output to stdout or not(true in default).
LevelPrint bool `json:"levelPrint" d:"true" dc:"Print level string|i18n:config.logger.levelPrint"` // Print level format string or not(true in default).
LevelPrefixes map[int]string `json:"levelPrefixes"` // Logging level to its prefix string mapping.
RotateSize int64 `json:"rotateSize" d:"0" dc:"Rotate file size in bytes (0=disabled)|i18n:config.logger.rotateSize"` // Rotate the logging file if its size > 0 in bytes.
RotateExpire time.Duration `json:"rotateExpire" d:"0" dc:"Rotate file expire duration|i18n:config.logger.rotateExpire"` // Rotate the logging file if its mtime exceeds this duration.
RotateBackupLimit int `json:"rotateBackupLimit" d:"0" dc:"Max rotated backup files|i18n:config.logger.rotateBackupLimit"` // Max backup for rotated files, default is 0, means no backups.
RotateBackupExpire time.Duration `json:"rotateBackupExpire" d:"0" dc:"Rotated backup file expire|i18n:config.logger.rotateBackupExpire"` // Max expires for rotated files, which is 0 in default, means no expiration.
RotateBackupCompress int `json:"rotateBackupCompress" d:"0" dc:"Gzip compress level for backup|i18n:config.logger.rotateBackupCompress"` // Compress level for rotated files using gzip algorithm. It's 0 in default, means no compression.
RotateCheckInterval time.Duration `json:"rotateCheckInterval" d:"1h" dc:"Async rotate check interval|i18n:config.logger.rotateCheckInterval"` // Asynchronously checks the backups and expiration at intervals. It's 1 hour in default.
StdoutColorDisabled bool `json:"stdoutColorDisabled" d:"false" dc:"Disable stdout color|i18n:config.logger.stdoutColorDisabled"` // Logging level prefix with color to writer or not (false in default).
WriterColorEnable bool `json:"writerColorEnable" d:"false" dc:"Enable writer color|i18n:config.logger.writerColorEnable"` // Logging level prefix with color to writer or not (false in default).
Handlers []Handler `json:"-"` // Logger handlers which implement feature similar as middleware.
Writer io.Writer `json:"-"` // Customized io.Writer.
Flags int `json:"flags"` // Extra flags for logging output features.
TimeFormat string `json:"timeFormat"` // Logging time format
Path string `json:"path"` // Logging directory path.
File string `json:"file"` // Format pattern for logging file.
Level int `json:"level"` // Output level.
Prefix string `json:"prefix"` // Prefix string for every logging content.
StSkip int `json:"stSkip"` // Skipping count for stack.
StStatus int `json:"stStatus"` // Stack status(1: enabled - default; 0: disabled)
StFilter string `json:"stFilter"` // Stack string filter.
CtxKeys []any `json:"ctxKeys"` // Context keys for logging, which is used for value retrieving from context.
HeaderPrint bool `json:"header"` // Print header or not(true in default).
StdoutPrint bool `json:"stdout"` // Output to stdout or not(true in default).
LevelPrint bool `json:"levelPrint"` // Print level format string or not(true in default).
LevelPrefixes map[int]string `json:"levelPrefixes"` // Logging level to its prefix string mapping.
RotateSize int64 `json:"rotateSize"` // Rotate the logging file if its size > 0 in bytes.
RotateExpire time.Duration `json:"rotateExpire"` // Rotate the logging file if its mtime exceeds this duration.
RotateBackupLimit int `json:"rotateBackupLimit"` // Max backup for rotated files, default is 0, means no backups.
RotateBackupExpire time.Duration `json:"rotateBackupExpire"` // Max expires for rotated files, which is 0 in default, means no expiration.
RotateBackupCompress int `json:"rotateBackupCompress"` // Compress level for rotated files using gzip algorithm. It's 0 in default, means no compression.
RotateCheckInterval time.Duration `json:"rotateCheckInterval"` // Asynchronously checks the backups and expiration at intervals. It's 1 hour in default.
StdoutColorDisabled bool `json:"stdoutColorDisabled"` // Logging level prefix with color to writer or not (false in default).
WriterColorEnable bool `json:"writerColorEnable"` // Logging level prefix with color to writer or not (false in default).
internalConfig
}

Some files were not shown because too many files have changed in this diff Show More