diff --git a/.ai/SPATIAL_STARTER_V2_REVIEW.md b/.ai/SPATIAL_STARTER_V2_REVIEW.md new file mode 100644 index 00000000..097ae970 --- /dev/null +++ b/.ai/SPATIAL_STARTER_V2_REVIEW.md @@ -0,0 +1,101 @@ +# Oracle Spring Boot Spatial Starter — PM Review & V2 Planning + +*Review date: 2026-04-01* +*Branch reviewed: spatial-starter* + +This document records the product management review of the v1 spatial starter as a basis for v2 planning. + +--- + +## Overall Assessment + +Solid, well-scoped v1 that covers the essential read/write operations. The GeoJSON-first design is the right call for modern Spring developers. The concerns below are about gaps that will frustrate real spatial customers, not about what's been done wrong. + +--- + +## What's Working Well + +**Scope is appropriate.** The four core Oracle Spatial operators — `SDO_FILTER`, `SDO_RELATE`, `SDO_WITHIN_DISTANCE`, `SDO_NN` — are the ones most Spring developers will actually reach for. Covering them all in a first release is the right call. + +**GeoJSON-first is the right default.** It aligns with how REST APIs, mapping libraries (Leaflet, Mapbox, OpenLayers), and spatial web tooling works. Developers won't have to convert anything at the application layer. + +**The two-tier API makes sense.** `OracleSpatialGeoJsonConverter` as a lower-level building block and `OracleSpatialSqlBuilder` as the higher-level helper gives developers an escape hatch without abandoning the starter. + +**The sample application is concrete and useful.** San Francisco landmarks is tangible, the REST API structure matches what developers actually build, and the `near` vs `within` distinction is an important pattern to demonstrate. + +**Documentation links to Oracle docs.** The "Further Reading" section is well-curated and the usage notes on anti-patterns (no `SDO_NN` + `SDO_WITHIN_DISTANCE` in the same `WHERE`) are the kind of guidance that saves developers hours. + +--- + +## V2 Candidates: Gaps That Will Frustrate Spatial Customers + +### 1. SDO_GEOM Analytical Functions — Highest Priority + +`SDO_GEOM` functions are half the reason customers use Oracle Spatial for analysis, not just lookup. There is currently no support for: + +- `SDO_GEOM.SDO_DISTANCE(a, b, tolerance)` — distance between two stored geometries +- `SDO_GEOM.SDO_AREA(geom, tolerance)` — polygon area calculation +- `SDO_GEOM.SDO_BUFFER(geom, distance, tolerance)` — buffer zone generation +- `SDO_GEOM.SDO_INTERSECTION`, `SDO_UNION`, `SDO_DIFFERENCE` — geometry construction + +A customer building "find all properties within the flood buffer zone" has no help from the starter today. They must hand-write all `SDO_GEOM` SQL themselves. Minimum v2 scope: `SDO_DISTANCE` and `SDO_BUFFER` as documented helpers on `OracleSpatialSqlBuilder`. + +### 2. SDO_RELATE Mask Constants or Enum — Significant Usability Risk + +The `relatePredicate()` method takes a raw `String mask` parameter. Oracle's valid mask values are not obvious: + +``` +ANYINTERACT, CONTAINS, COVEREDBY, DISJOINT, EQUAL, INSIDE, ON, +OVERLAPBDYDISJOINT, OVERLAPBDYINTERSECT, TOUCH +``` + +A developer who passes `"INTERSECTS"` (the PostGIS equivalent) will get a runtime database error with no helpful message. A `SpatialRelationMask` enum or a documented constants class would prevent a category of support tickets and make the API self-documenting. This is a low-effort, high-impact change. + +### 3. Sample Application — Geometry Type Diversity + +The sample inserts only Point geometries. Golden Gate Park is stored as a Point, not the polygon it actually is. This misses the chance to show: + +- Storing a Polygon and querying *which points fall inside it* using `SDO_RELATE` with `INSIDE` +- Storing a LineString (e.g., a route or path) and querying proximity to it + +Developers building anything beyond "store a pin on a map" need to see that Oracle Spatial handles complex geometry types and that the starter works with all of them. + +### 4. Schema Setup Guidance — New User Friction + +The starter correctly says "manage your own schema," but the DDL for `USER_SDO_GEOM_METADATA` registration and `MDSYS.SPATIAL_INDEX_V2` creation is unfamiliar to most Spring developers. It is currently buried in the sample SQL scripts. A "Getting Started" section in `spatial.md` showing the three-step schema setup (table → metadata → index) would reduce first-run friction substantially. + +### 5. WKT/WKB Support + +Oracle supports `SDO_UTIL.FROM_WKTGEOMETRY`, `TO_WKTGEOMETRY`, `FROM_WKBGEOMETRY`, `TO_WKBGEOMETRY`. Many spatial tools (QGIS, PostGIS migrations, GIS file exports) produce WKT or WKB. A customer migrating data from PostGIS or reading shapefiles will hit this gap immediately. At minimum v2 should either add WKT/WKB helper methods or explicitly document the limitation with a workaround pattern. + +--- + +## V2 Candidates: Oracle 26ai Positioning + +This starter ships alongside Oracle 26ai but does not position itself relative to the Oracle AI story. Oracle's differentiator is combining spatial with AI — e.g., using vector search to find semantically similar documents *near a location*. The docs do not mention this angle at all. + +Even a single paragraph in `spatial.md` — "Oracle Spatial works alongside Oracle AI Vector Search; combine spatial predicates with vector similarity in the same query for location-aware AI applications" — would make the 26ai positioning clear and hint at the integration pattern without requiring the starter to do more. A companion sample or recipe showing a combined spatial + vector query would be a strong differentiator. + +--- + +## V2 Candidates: Minor Documentation Issues + +- `spatial.md` lists all `OracleSpatialSqlBuilder` methods but provides no inline SQL example output. A developer reading the docs cannot tell what SQL `nearestNeighborDistanceProjection("dist")` actually generates without running it. Add one-line output examples for each method. +- The README `Example` section is too minimal — it shows injection but no query. The site doc's `create()` example is much better and should be the README example too. +- `default-distance-unit` documentation lists `M`, `KM`, and `UNIT=MILE` as valid values but does not explain why the format differs between `M`/`KM` and `UNIT=MILE`. That inconsistency will confuse developers who try to compose unit strings themselves. + +--- + +## Priority Summary + +| Gap | Priority | Effort | +|---|---|---| +| SDO_GEOM analytical functions (distance, area, buffer) | High | Medium | +| SDO_RELATE mask constants / enum | High | Low | +| Sample geometry diversity (Polygon, LineString) | Medium | Low | +| Schema setup Getting Started section in docs | Medium | Low | +| WKT/WKB support or documented limitation | Medium | Low–Medium | +| Oracle 26ai / AI Vector positioning in docs | Medium | Low | +| Inline SQL output examples in method docs | Low | Low | +| README example with a real query | Low | Low | +| Distance unit format explanation | Low | Low | diff --git a/.ai/oracle-spatial-jdbc-idiomatic-redesign-plan.md b/.ai/oracle-spatial-jdbc-idiomatic-redesign-plan.md new file mode 100644 index 00000000..b1b62644 --- /dev/null +++ b/.ai/oracle-spatial-jdbc-idiomatic-redesign-plan.md @@ -0,0 +1,396 @@ +# Oracle Spatial Starter — Spring JDBC Idiomatic Redesign Plan + +## Purpose + +This plan updates the earlier redesign proposal based on reviewer feedback and on patterns already used in this repository. The goal is to replace the current public API based on raw SQL string fragments with something that feels more like a Spring starter: dependency aggregation plus Spring JDBC integration points. + +This is a planning document only. No implementation should begin until we review and agree on the target design. + +## Important Change in Assumptions + +The current spatial API has **not** been released. That means: + +- we do **not** need to preserve compatibility with the current string-builder API +- we do **not** need a staged migration plan +- we can redesign the public surface cleanly now if we agree on a better shape + +This is a major simplification and should push us toward choosing the best API, not the most backward-compatible one. + +## Reviewer Direction + +The key reviewer guidance is: + +- the starter should provide dependency aggregation and Spring API integrations +- if we are writing a SQL utility, it should work with `JdbcClient`, `JdbcTemplate`, `RowMapper`, or other Spring abstractions +- raw SQL strings as the main public API are problematic + +## Repo Patterns Worth Reusing + +## 1. JSON data-tools uses Spring integration objects, not raw SQL fragments + +The clearest relevant pattern is `JSONBRowMapper` in `oracle-spring-boot-json-data-tools`: + +- it exposes a Spring `RowMapper` +- it integrates Oracle-specific behavior with Spring JDBC +- application code consumes a Spring abstraction rather than ad hoc strings + +This suggests the spatial starter should expose Spring-oriented integration types where possible. + +## 2. Duality module keeps SQL building internal + +`DualityViewBuilder` in `oracle-spring-boot-json-relational-duality-views` does build SQL, but: + +- it owns the build process internally +- callers do not splice together partial SQL fragments +- the builder produces and executes coherent statements as part of a Spring-managed component + +This suggests a better spatial design would: + +- keep SQL generation internal to the starter +- expose Spring-friendly operations and mappers +- avoid making application code concatenate Oracle-specific fragments manually + +## Design Goal + +Redesign the spatial starter so that: + +- the public API is Spring JDBC-oriented +- Oracle SQL generation remains internal implementation detail as much as possible +- application code works with Spring-friendly objects or operations rather than raw SQL snippets +- the starter remains lightweight and Oracle-specific where needed + +## Non-Goals + +- Building a JPA / Hibernate Spatial starter +- Building a large query DSL comparable to jOOQ +- Introducing Spring Data repository support in the first redesign pass +- Hiding Oracle Spatial concepts completely + +## Current API Problems + +### 1. The main public API returns raw SQL strings + +`OracleSpatialSqlBuilder` returns fragments such as: + +- `SDO_FILTER(...) = 'TRUE'` +- `SDO_RELATE(...) = 'TRUE'` +- `SDO_WITHIN_DISTANCE(...) = 'TRUE'` +- `SDO_NN(...) = 'TRUE'` + +That forces application code to do string concatenation around Oracle-specific SQL. + +### 2. The current API is Spring-managed but not Spring-native + +The builder and converter are injectable beans, but their public return types are plain strings. This does not meaningfully integrate with `JdbcClient`, `JdbcTemplate`, or `RowMapper`. + +### 3. Clause intent is implicit + +Some methods are meant for: + +- `SELECT` +- `WHERE` +- `ORDER BY` + +but the API does not encode those distinctions, so misuse is easy. + +### 4. Bind handling is too low-level + +Callers must understand how `"shape"` turns into `:shape` and how to keep bind names aligned across SQL fragments and `JdbcClient` calls. + +## Recommended Direction + +## Core idea + +Replace the public string-builder API with a **Spring JDBC integration layer** built around: + +- Spring `RowMapper` support for spatial projections +- Spring-oriented spatial query operations +- internal SQL generation that callers do not assemble manually + +## Top-level recommendation + +Introduce a new primary bean: + +- `OracleSpatialJdbcOperations` + +This bean should become the main spatial entry point for Spring JDBC users. + +It should be designed for use with: + +- `JdbcClient` +- `JdbcTemplate` + +and should coordinate: + +- geometry conversion +- bind handling +- predicate assembly +- projection/mapping helpers + +## Proposed Public API Shape + +## 1. `OracleSpatialJdbcOperations` + +This bean would expose higher-level methods that produce Spring-friendly query parts or bind helpers rather than raw strings. + +Candidate responsibilities: + +- create bindable geometry parameters from GeoJSON +- provide projection helpers for `SDO_GEOMETRY -> GeoJSON` +- provide predicate/operator abstractions for filter, relate, within-distance, nearest-neighbor +- provide optional ordering/projection helpers for distance-based queries +- provide convenience mapping helpers where useful + +### Candidate method families + +- geometry parameter creation + - e.g. `geoJson(String json)` + - e.g. `geoJson(String json, int srid)` +- projection helpers + - e.g. `geoJsonColumn(String geometryColumn)` +- predicate helpers + - e.g. `filter(String geometryColumn, SpatialGeometry value)` + - e.g. `relate(String geometryColumn, SpatialGeometry value, SpatialRelationMask mask)` + - e.g. `withinDistance(String geometryColumn, SpatialGeometry value, Number distance)` + - e.g. `nearestNeighbor(String geometryColumn, SpatialGeometry value, int numResults)` +- ordering helpers + - e.g. `distanceOrder(String geometryColumn, SpatialGeometry value)` + +These methods should return typed objects, not strings. + +## 2. Typed query-part objects + +Introduce small immutable types such as: + +- `SpatialGeometry` +- `SpatialPredicate` +- `SpatialProjection` +- `SpatialOrder` + +These are not intended to become a giant DSL. Their purpose is to: + +- encode intent +- carry Oracle-specific SQL rendering internally +- make the public API more explicit and less stringly typed + +### Important distinction from the old API + +The application should not call `.sql()` on these objects and manually concatenate them in ordinary usage unless absolutely necessary. + +Instead, `OracleSpatialJdbcOperations` should provide helper methods that integrate them into Spring JDBC usage. + +## 3. Spring `RowMapper` support + +Add row-mapper support inspired by `JSONBRowMapper`. + +Candidate additions: + +- `GeoJsonRowMapper` + - maps a selected GeoJSON column into `String` +- `SpatialRowMappers` + - helper factory for common row-mapping patterns +- optional record/domain mapper helpers for sample-like cases + +### Why this matters + +This is the clearest place where the starter can provide a truly Spring-native abstraction instead of a raw SQL utility. + +## 4. Explicit mask support instead of raw string masks + +The PM review already called out the risk around raw `SDO_RELATE` masks. + +The redesign should replace raw string masks with something like: + +- `SpatialRelationMask` enum + +This aligns with the same overall goal: + +- fewer unstructured strings +- more self-documenting Spring application code + +## Candidate Design Options + +## Option A — Spring JDBC integration bean plus typed query parts (Recommended) + +### Characteristics + +- `OracleSpatialJdbcOperations` is the main API +- typed spatial query-part objects exist internally/publicly +- row mappers are added +- SQL rendering remains internal + +### Why this fits repo patterns + +- like JSON data-tools, it exposes Spring JDBC-friendly integration points +- like duality views, it keeps SQL-building ownership inside the starter + +### Recommendation + +This should be the primary design target. + +## Option B — Keep typed query parts but skip RowMapper work initially + +### Characteristics + +- redesign the query API +- defer row mappers + +### Why it is weaker + +- misses the most obvious Spring integration hook already proven useful in JSON data-tools +- responds only partially to the reviewer feedback + +### Recommendation + +Not preferred. We should include at least basic `RowMapper` support in the redesign. + +## Option C — Build repository abstractions now + +### Characteristics + +- Spring Data JDBC repository fragments or custom repository support + +### Why it is premature + +- larger scope +- depends on getting the core JDBC integration API right first +- not necessary to answer the reviewer’s current concern + +### Recommendation + +Defer repository support until after the new JDBC-oriented API exists. + +## Reframed Role of Existing Classes + +## `OracleSpatialSqlBuilder` + +Recommendation: + +- do **not** keep this as the main public API +- either remove it entirely or reduce it to an internal implementation detail + +Because the current API is unreleased, we should feel free to replace it rather than carrying it forward. + +## `OracleSpatialGeoJsonConverter` + +Recommendation: + +- likely keep the core conversion logic +- but reconsider whether it remains a prominently documented public bean + +Two reasonable options: + +1. keep it public as a lower-level advanced helper +2. fold its responsibilities into `OracleSpatialJdbcOperations` and make it internal/package-private + +### Current recommendation + +Keep it for now as an internal building block until we settle the final top-level API. We can decide later whether it still deserves public status. + +## What “Idiomatic” Should Mean Here + +For this starter, “idiomatic” should mean: + +- users inject Spring beans that help them use Oracle Spatial with `JdbcClient` / `JdbcTemplate` +- users do not manually assemble Oracle SQL fragments in normal usage +- mapping support is provided through Spring interfaces like `RowMapper` +- Oracle-specific SQL still exists, but it is encapsulated behind starter-owned components + +It does **not** need to mean: + +- no SQL knowledge required +- full repository abstraction on day one +- a fully generic persistence model independent of Oracle Spatial + +## Sample Application Direction + +The spatial sample should be rewritten to demonstrate the new design. + +Instead of: + +```java +jdbcClient.sql("select ... " + sqlBuilder.geometryToGeoJson("geometry") + " ...") +``` + +the sample should show: + +- injecting `OracleSpatialJdbcOperations` +- using spatial predicates/projections owned by the starter +- using Spring-friendly row mapping where appropriate + +The sample should become the canonical usage example for the new API. + +## Testing Plan + +## 1. Replace string-builder API tests with operations-oriented tests + +Add tests for: + +- geometry parameter creation +- relation mask enum usage +- projection helpers +- distance helpers +- row mappers + +## 2. Keep real Oracle integration coverage + +Retain Testcontainers integration tests against Oracle Free to verify: + +- generated SQL is still correct +- binds are applied correctly +- mapping still works end to end + +## 3. Update sample test to reflect the new idiomatic API + +The sample should validate: + +- app wiring +- endpoint behavior +- underlying starter usage pattern + +## Documentation Plan + +- Rewrite starter docs around `OracleSpatialJdbcOperations` +- Add examples showing: + - injection into Spring services + - row mapper usage + - relation mask enum usage + - distance and near-query patterns +- Remove emphasis on raw SQL fragments from README and site docs + +## Implementation Sequence + +1. Define the target public API for `OracleSpatialJdbcOperations`. +2. Define the supporting types: + - `SpatialGeometry` + - `SpatialPredicate` + - `SpatialProjection` + - `SpatialOrder` + - `SpatialRelationMask` +3. Decide the fate of `OracleSpatialGeoJsonConverter`: + - internal only, or + - advanced public helper +4. Implement basic row-mapper support. +5. Rebuild the sample app on the new API. +6. Update tests. +7. Rewrite docs. + +## Open Questions For Discussion + +1. Should `OracleSpatialJdbcOperations` itself expose “apply to JdbcClient” helpers, or should it only produce typed spatial components? +2. How much Spring-specific behavior should live in the query-part objects themselves? +3. Should `SpatialRelationMask` be an enum only, or enum plus escape hatch for advanced masks? +4. Should the first redesign pass include any repository-oriented abstraction at all, or explicitly defer it? +5. Should `OracleSpatialGeoJsonConverter` survive as a public bean, or should we collapse conversion behind the new operations API? + +## Recommended Decision + +For the next discussion, I recommend we align on this target: + +- primary API: `OracleSpatialJdbcOperations` +- include Spring `RowMapper` support from the start +- keep SQL building internal to the starter +- remove `OracleSpatialSqlBuilder` as the intended public API +- defer repository support until after the JDBC-oriented API is stable + +If we agree on that direction, the redesign can be implemented cleanly without worrying about backward compatibility with the current unreleased string-builder API. diff --git a/.ai/oracle-spatial-starter-plan.md b/.ai/oracle-spatial-starter-plan.md new file mode 100644 index 00000000..d90501ef --- /dev/null +++ b/.ai/oracle-spatial-starter-plan.md @@ -0,0 +1,81 @@ +# Oracle Spatial Starter v1 Plan + +## Summary + +- Add a new two-module spatial addition that mirrors the existing JSON pattern: + - `oracle-spring-boot-spatial-data-tools` for the actual auto-configuration and helper beans + - `oracle-spring-boot-starter-spatial` as the thin dependency starter +- Add a new sample module: `oracle-spring-boot-sample-spatial`. +- Scope v1 to Oracle-native core vector spatial support only: `SDO_GEOMETRY`, GeoJSON conversion, metadata/index setup through SQL, and helper support for `SDO_FILTER`, `SDO_RELATE`, `SDO_WITHIN_DISTANCE`, and `SDO_NN`. + +## Key Changes + +- Wire the new modules into `database/starters/pom.xml` and the sample into `oracle-spring-boot-starter-samples/pom.xml`, using the same parent, versions, plugins, and packaging conventions already used across the database starters. +- In `oracle-spring-boot-spatial-data-tools`, add `com.oracle.spring.spatial` as the main package and register auto-configuration through both `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` and `META-INF/spring.factories`. +- Make the data-tools module depend on: + - `oracle-spring-boot-starter-ucp` + - `spring-boot-starter-jdbc` + - `spring-boot-configuration-processor` + - the same test/testcontainers stack already used by the other starter modules +- Expose these public types: + - `OracleSpatialAutoConfiguration` + - `OracleSpatialProperties` with prefix `oracle.database.spatial` + - `OracleSpatialGeoJsonConverter` + - `OracleSpatialSqlBuilder` +- Keep the v1 Java API GeoJSON-first and `JdbcClient`-friendly: + - `OracleSpatialGeoJsonConverter` handles read/write conversion boundaries around `SDO_UTIL.FROM_GEOJSON` and `SDO_UTIL.TO_GEOJSON` + - `OracleSpatialSqlBuilder` generates the Oracle SQL fragments needed for inserts/projections and the core predicates for `filter`, `relate`, `within-distance`, and nearest-neighbor queries + - application code continues to use normal Spring `JdbcClient` / `JdbcTemplate` with dependency injection rather than a custom repository framework +- Default properties: + - `oracle.database.spatial.enabled=true` + - `oracle.database.spatial.default-srid=4326` + - `oracle.database.spatial.default-distance-unit=M` +- Auto-configuration conditions: + - require a `DataSource` + - require Oracle JDBC classes on the classpath + - back off on user-defined beans of the same type +- Keep schema and index creation out of the library beans. Spatial table DDL, `USER_SDO_GEOM_METADATA`, and `MDSYS.SPATIAL_INDEX_V2` creation belong in sample SQL and user migrations, not hidden startup behavior. + +## Sample + +- Add `oracle-spring-boot-sample-spatial` under `oracle-spring-boot-starter-samples`. +- Build it as a small REST app using `spring-boot-starter-web`, `JdbcClient`, and `oracle-spring-boot-starter-spatial`. +- Use a simple domain such as `Landmark` with: + - `id` + - `name` + - `category` + - `geometry` represented as GeoJSON in the HTTP API +- Implement endpoints that demonstrate the starter clearly: + - `POST /landmarks` to create a row from GeoJSON + - `GET /landmarks/{id}` to return GeoJSON + - `GET /landmarks/near` for nearest-neighbor or within-distance point search + - `POST /landmarks/within` for polygon containment / area search +- Add SQL init scripts that: + - create the sample table with an `SDO_GEOMETRY` column + - populate `USER_SDO_GEOM_METADATA` + - create the spatial index + - seed a few rows using GeoJSON conversion +- Keep the sample consistent with existing repo samples: + - README with dependency coordinates and test command + - `application.yaml` + - integration-style verification rather than a purely illustrative skeleton + +## Test Plan + +- Add a starter auto-configuration test that verifies the spatial beans load when `DataSource` is present and back off correctly when the user provides overrides. +- Add integration tests for the data-tools module using Testcontainers to verify: + - GeoJSON -> `SDO_GEOMETRY` -> GeoJSON round-trip + - basic insert/select through injected Spring beans + - `SDO_FILTER` + - `SDO_RELATE` + - `SDO_WITHIN_DISTANCE` + - `SDO_NN` +- Add a sample `@SpringBootTest` + Testcontainers test that exercises the REST flow end to end against seeded spatial data. +- Keep the test style aligned with the existing sample modules: `@SpringBootTest`, `@DynamicPropertySource`, `OracleContainer`, and SQL initialization. + +## Assumptions + +- v1 is intentionally not a JPA/Hibernate Spatial starter. +- v1 will not introduce JTS or another external geometry model; the public boundary stays GeoJSON-oriented to keep the starter lightweight and REST-friendly. +- The implementation should follow the existing repo’s module layout and code style, especially the JSON starter split and the current database-starters parent build. +- Automated tests should continue to use the repo’s current Oracle Free Testcontainers approach unless the repository explicitly standardizes on a different 26ai-compatible image later. diff --git a/.ai/oracle-spatial-starter-review-implementation-plan.md b/.ai/oracle-spatial-starter-review-implementation-plan.md new file mode 100644 index 00000000..69226359 --- /dev/null +++ b/.ai/oracle-spatial-starter-review-implementation-plan.md @@ -0,0 +1,242 @@ +# Oracle Spatial Starter Review Follow-Up Plan + +## Purpose + +This plan turns the recommendations in `oracle-spatial-starter-review.md` into a concrete implementation sequence. It is intentionally limited to planning only so we can review scope and tradeoffs before making code changes. + +## Goals + +- Address the architect's pre-merge concerns without expanding v1 beyond its intended scope. +- Preserve the current module structure and repository conventions. +- Prioritize correctness, API clarity, and test coverage over adding new features. + +## Recommended Scope + +### In scope for this follow-up + +- Fix the sample application's `near` query so it uses a correct Oracle Spatial pattern. +- Improve the public spatial API with Javadoc and small overloads that reduce friction for real users. +- Add validation and documentation to `OracleSpatialProperties`. +- Add missing auto-configuration and behavior tests called out in the review. +- Clarify documentation so users understand: + - this starter targets `SDO_GEOMETRY`, not Oracle AI `VECTOR` + - the sample `GET /landmarks/near` endpoint expects compact GeoJSON in a query parameter + - production applications should add input validation and use schema migrations for metadata/index setup + +### Out of scope for this follow-up + +- Introducing JPA / Hibernate Spatial support +- Introducing JTS or another geometry model +- Redesigning the sample API from `GET /landmarks/near` to a `POST` request +- Supporting every advanced Oracle Spatial edge case in v1 +- Adding vector-search features related to Oracle `VECTOR` + +## Workstreams + +## 1. Correct the sample `near` query semantics + +### Problem + +The current sample combines `SDO_WITHIN_DISTANCE` and `SDO_NN` in the same query path. The review flags this as an incorrect Oracle Spatial usage pattern that may yield errors or misleading behavior. + +### Plan + +- Refactor `LandmarkService.findNear(...)` to use one primary spatial strategy instead of combining operators in the same predicate set. +- Keep the endpoint contract compatible with the existing sample unless we decide otherwise during review. +- Prefer a query shape that is easy to explain in the README and stable for sample/demo purposes. + +### Recommended implementation direction + +- Treat `distance` as the primary search constraint for the sample endpoint. +- Use `SDO_WITHIN_DISTANCE` to filter candidates. +- Order the filtered results by `SDO_NN_DISTANCE(...)` expression or another valid Oracle-supported distance ordering pattern already compatible with the builder. +- Apply `FETCH FIRST N ROWS ONLY` using the requested `limit`. + +### Follow-up note + +- Document in code and README that `SDO_NN` and `SDO_WITHIN_DISTANCE` should not be casually combined in a single WHERE clause. + +## 2. Improve `OracleSpatialGeoJsonConverter` + +### Problem + +The current converter forces a single SRID and distance unit per bean instance, which makes per-query overrides awkward. + +### Plan + +- Keep the current methods for backward compatibility. +- Add overloads for the common per-query override cases: + - `fromGeoJsonSql(String bindExpression, int srid)` + - `distanceClause(Number distance, String unit)` +- Add Javadoc describing expected inputs: + - bind expressions should already be valid SQL fragments + - callers pass bind parameter references such as `:geometry` + - null or invalid values are caller errors + +### Validation approach + +- Do not over-engineer this with a large type system in v1. +- Prefer clear Javadoc plus lightweight validation where it materially improves failure messages. + +## 3. Improve `OracleSpatialSqlBuilder` + +### Problem + +The builder is functional but under-documented, and a few methods are unclear to users without reading generated SQL. + +### Plan + +- Add Javadoc to every public method. +- Document for each method: + - the SQL fragment it generates + - what kind of argument each parameter expects + - whether the return value is intended for `SELECT`, `WHERE`, or `ORDER BY` + - any Oracle-specific caveats +- Explicitly document: + - blank `mask` values normalize to `ANYINTERACT` + - the distinction between `nearestNeighborDistanceExpression()` and `nearestNeighborDistanceProjection(alias)` + - the `SDO_NN_DISTANCE(1)` identifier assumption and its limitations + +### Optional small API improvements + +- Evaluate whether any builder methods should accept a bind name that is more explicit in sample code, such as `refGeometry`, without renaming the existing public API. +- Keep compatibility with existing tests and sample code unless a rename materially improves clarity. + +## 4. Strengthen `OracleSpatialProperties` + +### Problem + +The properties are sensible but undocumented and lightly validated. + +### Plan + +- Add class-level and field-level Javadoc so Spring configuration metadata is useful in IDEs. +- Add validation for: + - `defaultSrid` must be positive + - `defaultDistanceUnit` must be non-blank and in a supported format + +### Validation options + +- Preferred: lightweight setter or initialization validation with clear exception messages. +- Alternative: Bean Validation annotations if that fits the repository's current style in starter modules. + +### Decision to make before implementation + +- Decide whether to validate `defaultDistanceUnit` as: + - a small allowlist of common units such as `M`, `KM`, `MILE` + - a looser string-format check that preserves Oracle flexibility + +### Recommendation + +- Use a looser validation rule for v1 so we do not reject Oracle-supported formats like `UNIT=MILE`. + +## 5. Expand auto-configuration tests + +### Problem + +Two important conditional cases are currently untested. + +### Plan + +- Add a test verifying that no spatial beans are created when no `DataSource` bean is available. +- Add a test verifying that no spatial beans are created when Oracle JDBC classes are absent, using `FilteredClassLoader`. +- Keep the existing tests for: + - default bean creation + - `enabled=false` + - user bean override + +### Test style + +- Follow Spring Boot auto-configuration test conventions. +- Prefer focused context-runner style tests if the existing module already supports that cleanly; otherwise stay consistent with the current test style in this module. + +## 6. Expand integration and behavior tests + +### Problem + +The review identified a few behavioral cases that should be covered explicitly. + +### Plan + +- Add a test for blank `SDO_RELATE` mask normalization to `ANYINTERACT`. +- Add or update tests around the sample `near` behavior after the query fix. +- Ensure the integration tests reflect the intended contract: + - nearest-neighbor support works + - within-distance support works + - the sample does not rely on an invalid combination of both patterns + +### Test design guidance + +- Keep the passing Oracle Testcontainers setup already established for the spatial modules. +- Reuse the current `gvenzl/oracle-free:23.26.0-full-faststart` image for spatial tests unless we consciously revisit image strategy later. + +## 7. Improve README and user-facing docs + +### Problem + +The library and sample docs do not yet make a few important boundaries explicit. + +### Plan + +- Update the spatial starter README and site docs to clarify: + - this module is for geographic/topographic `SDO_GEOMETRY` + - it is not a vector-search starter for Oracle AI embeddings + - schema metadata and spatial indexes belong in migrations or setup SQL + - sample `GET /landmarks/near` expects compact GeoJSON in the query string + - production applications should add validation around request payloads and query parameters + +### Nice-to-have + +- Include a short section with query composition guidance: + - when to use `SDO_FILTER` + - when to use `SDO_RELATE` + - when to use `SDO_WITHIN_DISTANCE` + - when to use `SDO_NN` + +## Proposed Sequence + +1. Fix the sample `near` query semantics first, because it is the most important correctness issue. +2. Add or update tests that lock in the corrected query behavior. +3. Add missing auto-configuration conditional tests. +4. Add `OracleSpatialProperties` validation and Javadoc. +5. Add converter and SQL builder overloads plus public API Javadoc. +6. Refresh README and site docs last so they reflect the final API and sample behavior. + +## Expected File Areas + +- `database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/` +- `database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/` +- `database/starters/oracle-spring-boot-starter-spatial/README.md` +- `site/docs/database/spatial.md` + +## Risks and Tradeoffs + +- Adding validation that is too strict could reject valid Oracle-specific distance-unit strings. +- Changing sample query behavior without carefully updating tests could make the REST sample less intuitive. +- Expanding the public API should remain incremental; we should avoid turning v1 into a full query DSL. + +## Open Questions For Review + +- Should the sample `near` endpoint remain a `GET` for plan consistency, or should we defer any API-shape improvement to a later iteration? +- Do we want only documentation for `SDO_NN_DISTANCE(1)` limitations, or do we want a configurable operator number in v1? +- How strict should property validation be for `defaultDistanceUnit`? +- Do we want to keep all current public method names exactly as-is and only add overloads/Javadoc, or is there appetite for small naming cleanup where confusion is high? + +## Agreed Direction + +- Keep `GET /landmarks/near` for v1 to stay aligned with the original plan and avoid unnecessary API churn. Document clearly that the `geometry` query parameter should be compact GeoJSON. +- Validate `oracle.database.spatial.default-distance-unit` loosely rather than with a strict enum so Oracle-supported values such as `UNIT=MILE` remain valid. +- Treat the hardcoded `SDO_NN_DISTANCE(1)` operator identifier as a documented v1 limitation rather than introducing configurable operator numbering in this pass. +- Preserve the current public method names and improve the API by adding overloads and Javadoc instead of renaming methods. +- Keep this follow-up as a focused hardening pass: correctness fixes, test coverage, documentation, and a few low-risk API improvements, but no broader DSL or transport redesign. + +## Suggested Outcome + +If we agree with this plan, the implementation can proceed as a focused hardening pass rather than a redesign. The likely deliverable is a merge-ready v1 with: + +- corrected sample query behavior +- stronger docs and configuration metadata +- a more practical SQL helper API +- fuller conditional and integration test coverage diff --git a/.ai/oracle-spatial-starter-review-revision-2-plan.md b/.ai/oracle-spatial-starter-review-revision-2-plan.md new file mode 100644 index 00000000..6d91818b --- /dev/null +++ b/.ai/oracle-spatial-starter-review-revision-2-plan.md @@ -0,0 +1,137 @@ +# Oracle Spatial Starter Review Revision 2 Follow-Up Plan + +## Purpose + +This plan captures the remaining work identified in `oracle-spatial-starter-review.md` after the first hardening pass. The updated review says the starter is close to merge-ready, so this plan is intentionally small and focused on the last cleanup items. + +## Goal + +- Resolve the three P1 issues before merge. +- Pick up the small P2 and P3 polish items at the same time if they stay low-risk. +- Avoid reopening already-resolved design questions. + +## Remaining Issues From Review + +### P1 items to address before merge + +1. Remove dead `OverrideBeans` code from `OracleSpatialAutoConfigurationTest`. +2. Add a configuration properties table to the starter README. +3. Update seed SQL to use the explicit three-argument `sdo_util.from_geojson(..., null, 4326)` form. + +### P2 items worth fixing in the same pass + +1. Rename the `findWithin` bind parameter from `geometry` to `refGeometry` for clarity. +2. Tighten the README query guidance so it says not to combine `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same `WHERE` clause. + +### P3 polish items + +1. Switch the `FilteredClassLoader` test to the class-reference form if practical. +2. Add a short code comment explaining the `0.005` tolerance used with `SDO_GEOM.SDO_DISTANCE`. + +## Proposed Workstreams + +## 1. Remove dead test code + +### Problem + +`OracleSpatialAutoConfigurationTest` contains an `OverrideBeans` inner class that is not imported into the test application and is therefore dead code. It duplicates coverage that already exists in `OracleSpatialOverrideAutoConfigurationTest`. + +### Plan + +- Delete the unused `OverrideBeans` inner class from `OracleSpatialAutoConfigurationTest`. +- Keep the test focused on the default auto-configuration case only. +- Verify the override behavior remains covered solely by `OracleSpatialOverrideAutoConfigurationTest`. + +## 2. Make seed SQL consistent with runtime SQL generation + +### Problem + +The init scripts currently call `sdo_util.from_geojson(...)` without the SRID argument, even though the starter-generated SQL uses the explicit SRID form and the sample metadata declares SRID `4326`. + +### Plan + +- Update both: + - `database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/spatial-init.sql` + - `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/resources/init.sql` +- Change every seeded geometry insert to: + - `sdo_util.from_geojson('', null, 4326)` +- Keep the rest of the schema and seed data unchanged. + +### Outcome + +- Test SQL better reflects the recommended pattern users should copy. +- Seed data becomes consistent with `OracleSpatialGeoJsonConverter#fromGeoJsonSql(...)`. + +## 3. Improve README completeness and accuracy + +### Problem + +The starter README still lacks a configuration properties table, and the current `SDO_NN` guidance is too soft. + +### Plan + +- Add a `Configuration Properties` section to `database/starters/oracle-spring-boot-starter-spatial/README.md` with: + - `oracle.database.spatial.enabled` + - `oracle.database.spatial.default-srid` + - `oracle.database.spatial.default-distance-unit` +- Include type, default, and short description for each property. +- Update the query guidance wording to say: + - do not combine `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same `WHERE` clause + - use `SDO_WITHIN_DISTANCE` for radius filtering + - use `SDO_NN` for nearest-neighbor queries + - use `SDO_WITHIN_DISTANCE` ordered by `SDO_GEOM.SDO_DISTANCE` when both a radius and ordering are needed + +## 4. Align sample clarity with the `findNear` cleanup + +### Problem + +`findNear` now uses `refGeometry`, but `findWithin` still uses `geometry` as both the column name and the bind parameter name, which is confusing in sample code. + +### Plan + +- Update `LandmarkService.findWithin(...)` to use `refGeometry` as the bind name. +- Keep the API contract unchanged; this is an internal SQL readability change only. +- Confirm the sample test still covers the endpoint behavior without requiring API changes. + +## 5. Apply low-risk polish if it stays trivial + +### Problem + +There are two small quality items left that are not important enough to justify a separate pass. + +### Plan + +- Update `OracleSpatialConditionalAutoConfigurationTest` to use: + - `new FilteredClassLoader(oracle.jdbc.OracleConnection.class)` + instead of the string form. +- Add a one-line comment near the `0.005` tolerance in `LandmarkService.findNear(...)` explaining that the value is the tolerance used by `SDO_GEOM.SDO_DISTANCE` for the seeded WGS84 sample data. + +## Proposed Sequence + +1. Remove dead code from `OracleSpatialAutoConfigurationTest`. +2. Update both init SQL files to include SRID `4326`. +3. Update `LandmarkService.findWithin(...)` bind naming and add the distance tolerance comment in `findNear(...)`. +4. Refresh the starter README with the properties table and stricter `SDO_NN` guidance. +5. Apply the `FilteredClassLoader` cosmetic cleanup. +6. Run focused tests for: + - spatial data-tools auto-configuration tests + - spatial data-tools integration tests + - spatial sample tests + +## Expected Files + +- `database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialAutoConfigurationTest.java` +- `database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialConditionalAutoConfigurationTest.java` +- `database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/spatial-init.sql` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkService.java` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/resources/init.sql` +- `database/starters/oracle-spring-boot-starter-spatial/README.md` + +## Risks + +- Seed SQL updates could break tests if Oracle interprets the three-argument form differently than expected in this image, so this should be verified with the existing Testcontainers suite. +- README wording changes should stay specific and instructional rather than over-explaining Oracle semantics already covered in the linked docs. + +## Suggested Outcome + +After this pass, the starter should satisfy the Revision 2 review's P1 issues and most of the remaining polish items, leaving the branch in a merge-ready state. diff --git a/.ai/oracle-spatial-starter-review.md b/.ai/oracle-spatial-starter-review.md new file mode 100644 index 00000000..ecee6d17 --- /dev/null +++ b/.ai/oracle-spatial-starter-review.md @@ -0,0 +1,229 @@ +# Oracle Spatial Starter — Code Review (Revision 2) +**Branch:** `spatial-starter` +**Reviewer:** Senior Architect Review +**Date:** 2026-03-31 +**Previous review:** 2026-03-31 (Revision 1) + +--- + +## Summary of Changes Made + +The developer addressed the previous review thoroughly. Every Priority 0 and Priority 1 item was handled, and all three Priority 2 items were also resolved. The implementation is materially better. What follows is an updated assessment that credits those improvements and identifies the smaller number of issues that remain. + +--- + +## What Was Fixed + +| Previous recommendation | Resolution | +|------------------------|------------| +| P0: `findNear` incorrectly combined `SDO_NN` + `SDO_WITHIN_DISTANCE` | Fixed — now uses `SDO_WITHIN_DISTANCE` for filtering and `SDO_GEOM.SDO_DISTANCE` for ordering | +| P1: Javadoc on all `OracleSpatialSqlBuilder` public methods | Done — each method documents the SQL it generates, parameter expectations, and the `mask` default | +| P1: Field-level Javadoc on `OracleSpatialProperties` | Done — class and all three fields documented | +| P1: `FilteredClassLoader` test for `@ConditionalOnClass` | Done — new `OracleSpatialConditionalAutoConfigurationTest` | +| P1: Test for absent `DataSource` | Done — same new test class | +| P1: `SDO_RELATE` mask test | Done — `blankRelateMaskDefaultsToAnyInteract` integration test | +| P1: README disambiguation: `SDO_GEOMETRY` vs Oracle 23ai `VECTOR` | Done in both READMEs | +| P2: Per-query unit override on `withinDistancePredicate` | Done — overloaded with `String unit` | +| P2: Per-query SRID override on `fromGeoJsonSql` and predicates | Done — overloaded with `int srid` | +| P2: Validate `defaultDistanceUnit` | Done — `Assert.hasText`, trim, no-quote guard in setter | +| P2: Rename bind param in `findNear` to avoid shadowing column name | Done — now `refGeometry` | + +--- + +## Detailed Assessment of Updated Code + +### `OracleSpatialProperties` — Good + +The setter-level validation is correct and proportionate: + +```java +public void setDefaultSrid(int defaultSrid) { + Assert.isTrue(defaultSrid > 0, "oracle.database.spatial.default-srid must be greater than 0"); + this.defaultSrid = defaultSrid; +} + +public void setDefaultDistanceUnit(String defaultDistanceUnit) { + Assert.hasText(defaultDistanceUnit, "..."); + String trimmed = defaultDistanceUnit.trim(); + Assert.isTrue(!trimmed.contains("'"), "..."); + this.defaultDistanceUnit = trimmed; +} +``` + +The single-quote injection guard in the distance unit is important because the unit value flows into a string literal inside Oracle SQL (e.g., `'distance=100 unit=M'`). This is well-considered. The validation fires at application startup via Spring's property binding, which is exactly the right moment. + +### `OracleSpatialGeoJsonConverter` — Excellent + +The additions are correct: +- `fromGeoJsonSql(String, int)` overload for per-query SRID — correct signature. +- `distanceClause(Number, String)` overload for per-query unit — correct, with the same quote guard. +- `Assert.hasText` guards on both `bindExpression` and `geometryExpression`. + +The Javadoc is accurate and useful. The doc comment on `fromGeoJsonSql(String, int)` correctly states "using the supplied SRID instead of the configured default," which is exactly the right framing for the overload pair. + +### `OracleSpatialSqlBuilder` — Excellent + +The Javadoc is the most significant improvement. The method comments accurately describe the Oracle SQL fragments produced, the bind parameter name convention (without leading colon), and the SDO_NN limitation: + +> This method hardcodes Oracle operator number `1`, so callers using multiple `SDO_NN` operators in the same SQL statement should build that query manually. + +The `normalize(mask)` behaviour is now also documented on `relatePredicate`. This is the right place for it rather than on the private method. + +The per-SRID overloads for `filterPredicate`, `relatePredicate`, `geometryFromGeoJson`, and `insertGeometryExpression` are consistent with the converter overloads and complete the API surface. + +### `OracleSpatialConditionalAutoConfigurationTest` — Good, One Minor Issue + +```java +@Test +void backsOffWhenNoDataSourceBeanIsPresent() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(OracleSpatialGeoJsonConverter.class); + assertThat(context).doesNotHaveBean(OracleSpatialSqlBuilder.class); + }); +} + +@Test +void backsOffWhenOracleJdbcClassesAreMissing() { + contextRunner + .withUserConfiguration(TestDataSourceConfiguration.class) + .withClassLoader(new FilteredClassLoader("oracle.jdbc.OracleConnection")) + ... +} +``` + +Both tests are correct in what they verify. The `ApplicationContextRunner` pattern is the right approach for `@ConditionalOn*` tests — it does not start a full Spring Boot context and runs in milliseconds. + +**Minor issue:** `new FilteredClassLoader("oracle.jdbc.OracleConnection")` uses the package-string constructor, which works here because `name.startsWith("oracle.jdbc.OracleConnection")` matches the class name. However, the idiomatic form for hiding a specific class is the class-reference constructor: + +```java +new FilteredClassLoader(oracle.jdbc.OracleConnection.class) +``` + +Using the class reference is unambiguous, avoids the prefix-match subtlety, and matches the pattern used in Spring Boot's own auto-configuration tests. This is a low-priority cosmetic issue since the current form works correctly. + +### `OracleSpatialIntegrationTest` — Good + +The `blankRelateMaskDefaultsToAnyInteract` test is correctly implemented and proves the `normalize()` behaviour against a real database. The existing `spatialPredicatesWork` test now uses `SDO_NN` alone (without `SDO_WITHIN_DISTANCE`) which is the correct Oracle Spatial usage pattern. + +**Observation:** The `@DynamicPropertySource` connects as `system` (Oracle superuser) rather than the `testuser` created by the container (`withUsername("testuser")`). This was present before and is technically fine for tests — `system` has the necessary privileges to create tables and insert into `USER_SDO_GEOM_METADATA`. However it is slightly inconsistent with the container configuration and may confuse readers. The sample test has the same pattern with its `USERNAME` env variable set to `"system"`. + +### `LandmarkService.findNear` — Corrected + +The P0 fix is correct: + +```java +String distanceProjection = "SDO_GEOM.SDO_DISTANCE(geometry, " + + sqlBuilder.geometryFromGeoJson("refGeometry") + + ", 0.005, 'unit=" + geoJsonConverter.defaultDistanceUnit() + "') distance"; +return jdbcClient.sql("select ..., " + distanceProjection + + " from landmarks where " + + sqlBuilder.withinDistancePredicate("geometry", "refGeometry", effectiveDistance) + + " order by distance fetch first " + effectiveLimit + " rows only") +``` + +`SDO_WITHIN_DISTANCE` filters, `SDO_GEOM.SDO_DISTANCE` computes the actual metric distance for ordering, and `FETCH FIRST N ROWS ONLY` limits results. This is a standard and correct Oracle Spatial pattern for "find N nearest within a radius" queries. + +The tolerance `0.005` in `SDO_GEOM.SDO_DISTANCE` is hardcoded. For WGS84 (SRID 4326) geodetic data, the tolerance parameter to `SDO_GEOM.SDO_DISTANCE` is specified in metres; `0.005` means 5 mm precision, which is reasonable. This could be worth a code comment since the magic number is not self-evident. + +`findWithin` still uses `geometry` as both the column name and the bind parameter name, which was a P2 item from the previous review. See Section 3 below. + +--- + +## Remaining Issues + +### Issue 1 — Dead `OverrideBeans` Inner Class in `OracleSpatialAutoConfigurationTest` + +`OracleSpatialAutoConfigurationTest` defines a `@TestConfiguration` inner class `OverrideBeans` with `srid=3857`, but the `TestApplication` in the same file only imports `Config.class`, not `OverrideBeans`: + +```java +@SpringBootConfiguration +@EnableAutoConfiguration +@Import(Config.class) // ← OverrideBeans is NOT imported +static class TestApplication { +} +``` + +Because `classes = TestApplication.class` is specified on `@SpringBootTest`, the `OverrideBeans` inner class is never registered in the application context. The test body correctly asserts the default `srid=4326`, which works only because the `OverrideBeans` are inactive. + +The `OverrideBeans` class is an exact copy of what already appears in `OracleSpatialOverrideAutoConfigurationTest`, which is where it belongs. It should be removed from `OracleSpatialAutoConfigurationTest` to eliminate dead code and avoid confusion about what the test is actually testing. + +### Issue 2 — README Missing Configuration Properties Table + +The starter README (`oracle-spring-boot-starter-spatial/README.md`) now has the query guidance section and the VECTOR distinction. What is still missing is a configuration properties reference. Without it, users must read the `OracleSpatialProperties` source to discover the available knobs. This was a P1 recommendation in the previous review. + +The following section should be added: + +```markdown +## Configuration Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `oracle.database.spatial.enabled` | `boolean` | `true` | Enables or disables the spatial auto-configuration | +| `oracle.database.spatial.default-srid` | `int` | `4326` | SRID embedded in generated `SDO_UTIL.FROM_GEOJSON` calls; must be positive | +| `oracle.database.spatial.default-distance-unit` | `String` | `M` | Distance unit token appended to generated distance clauses; Oracle-supported values include `M`, `KM`, `UNIT=MILE` | +``` + +### Issue 3 — Seed SQL Does Not Specify SRID in `sdo_util.from_geojson` Calls + +Both `spatial-init.sql` (data-tools test) and `init.sql` (sample test) insert seed rows without specifying the SRID: + +```sql +insert into landmarks (..., geometry) +values (1, 'Ferry Building', 'MARKET', + sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3933,37.7955]}')); +``` + +Without the SRID argument, `SDO_UTIL.FROM_GEOJSON` returns a geometry with `null` SRID. The metadata row registers SRID 4326. The tests pass because Oracle is generally tolerant of null SRID when metadata is present, but this is inconsistent and is a misleading pattern for users who copy from the init scripts. + +The consistent form — which matches how the starter generates SQL at runtime — is: + +```sql +sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3933,37.7955]}', null, 4326) +``` + +Both init scripts should be updated to use the three-argument form. + +### Issue 4 — `findWithin` Column/Bind Name Collision (P2, Still Outstanding) + +```java +jdbcClient.sql("... where " + + sqlBuilder.filterPredicate("geometry", "geometry") // col=geometry, bind=geometry + + " and " + sqlBuilder.relatePredicate("geometry", "geometry", mask)) + .param("geometry", geometry) +``` + +Using `"geometry"` as both the table column name and the bind parameter name is syntactically correct — Spring's `JdbcClient` resolves bind parameters by the `:name` form in the SQL, which does not conflict with the unqualified column name. However it reads ambiguously and is a poor pattern to demonstrate in a sample that users will copy. Renaming the bind parameter to `refGeometry` (as was done in `findNear`) would be consistent and clearer. + +### Issue 5 — SDO_NN Query Guidance Wording Is Imprecise + +The README Query Guidance section states: + +> Avoid combining `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same simple `WHERE` clause unless you are intentionally building a more advanced Oracle Spatial query pattern. + +The qualifier "simple... unless you are intentionally building a more advanced pattern" inadvertently suggests there are common advanced patterns where the combination is appropriate. Oracle's documentation advises against this combination without qualification. The guidance would be stronger without the escape clause: + +> Do not combine `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same `WHERE` clause. Use `SDO_WITHIN_DISTANCE` for radius filtering, `SDO_NN` for nearest-neighbor queries, or `SDO_WITHIN_DISTANCE` ordered by `SDO_GEOM.SDO_DISTANCE` when you need both a distance bound and a result count. + +--- + +## Updated Priority Table + +| Issue | Priority | Effort | +|-------|----------|--------| +| Dead `OverrideBeans` in `OracleSpatialAutoConfigurationTest` | P1 | Trivial — delete the inner class | +| README missing configuration properties table | P1 | Small — add ~10 lines | +| Seed SQL missing SRID in `from_geojson` calls | P1 | Trivial — update 3 rows in each of 2 SQL files | +| `findWithin` column/bind name collision | P2 | Trivial — rename bind param to `refGeometry` | +| SDO_NN README guidance wording | P2 | Trivial — one sentence edit | +| `FilteredClassLoader` string vs class reference | P3 | Trivial — cosmetic | +| `SDO_GEOM.SDO_DISTANCE` tolerance magic number | P3 | Add a code comment | + +--- + +## Overall Assessment + +The implementation is in very good shape and all significant concerns from the first review have been resolved. The P0 correctness bug is fixed correctly, the public API now has complete and useful Javadoc, the per-query SRID/unit overloads are implemented cleanly, and the new conditional tests cover the previously missing cases. + +What remains are four small issues and one trivial one. None of them affect runtime correctness for the common path — the P1 seed SQL inconsistency is the most likely to confuse a real user copying the init script pattern. The dead `OverrideBeans` inner class and the missing README properties table are polish items that should be addressed before the PR is merged but require very little work. + +**Recommendation: ready to merge after fixing the three P1 items.** diff --git a/.ai/pr-255-review-response-plan.md b/.ai/pr-255-review-response-plan.md new file mode 100644 index 00000000..132c8799 --- /dev/null +++ b/.ai/pr-255-review-response-plan.md @@ -0,0 +1,159 @@ +# PR 255 Review Response Plan + +## Source + +This plan responds to the current review comments on PR `oracle/spring-cloud-oracle#255`. + +## Review Comments To Address + +1. "we probably should not use string sql builders. can something similar be done with jdbcclient?" +2. "did you mean to check these files in?" on `.ai/oracle-spatial-starter-plan.md` +3. "prefer @ServiceConnection" +4. "use 23.26.1" + +## Goals + +- Resolve the straightforward review items directly in this branch. +- Separate the larger API design concern from the simple cleanup items so we do not mix a potentially broad redesign into a near-merge branch without agreement. +- Keep the PR focused and easy to review. + +## Recommended Approach + +## 1. Remove accidental `.ai` planning/review files from the PR + +### Problem + +The PR currently includes local planning and review artifacts under `.ai/`, and the reviewer has explicitly questioned whether they were meant to be checked in. + +### Plan + +- Remove the `.ai` files from the PR branch: + - `.ai/SPATIAL_STARTER_V2_REVIEW.md` + - `.ai/oracle-spatial-starter-plan.md` + - `.ai/oracle-spatial-starter-review-implementation-plan.md` + - `.ai/oracle-spatial-starter-review-revision-2-plan.md` + - `.ai/oracle-spatial-starter-review.md` +- Double-check whether any similar review artifact under the starter module path should also be removed if it is not intended for the repository. +- Re-run `git diff --stat` after removal to confirm the PR scope contains only product code, tests, and docs meant for merge. + +### Expected Outcome + +- The PR becomes cleaner and avoids repository-noise concerns. + +## 2. Upgrade Oracle Free Testcontainers image version to `23.26.1` + +### Problem + +The reviewer asked to use `23.26.1` instead of `23.26.0`. + +### Plan + +- Update all spatial Testcontainers references from: + - `gvenzl/oracle-free:23.26.0-full-faststart` + to: + - `gvenzl/oracle-free:23.26.1-full-faststart` +- Apply this consistently in: + - `OracleSpatialIntegrationTest` + - `SpatialSampleApplicationTest` +- Search the spatial modules for any remaining `23.26.0` references after the change. + +### Verification + +- Run the same Docker-backed tests already used for spatial validation: + - `mvn -pl oracle-spring-boot-spatial-data-tools clean test` + - `mvn -pl oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial -am clean test` + +## 3. Prefer `@ServiceConnection` for Testcontainers wiring + +### Problem + +The sample test currently uses `@DynamicPropertySource`, and the reviewer prefers `@ServiceConnection`. + +### Current State + +- `SpatialSampleApplicationTest` uses `@DynamicPropertySource`. +- `OracleSpatialIntegrationTest` also uses manual property registration. +- The sample application currently reads datasource settings from custom placeholders: + - `JDBC_URL` + - `USERNAME` + - `PASSWORD` +- That means adopting `@ServiceConnection` cleanly will likely require normalizing datasource configuration toward standard Spring Boot `spring.datasource.*` property usage, at least in tests or possibly in the sample app config. + +### Plan + +- Verify the repository’s current Spring Boot / Testcontainers version supports `@ServiceConnection` with `OracleContainer`. +- Refactor the spatial sample test first: + - annotate the container with `@ServiceConnection` + - remove `@DynamicPropertySource` + - update the sample datasource configuration to use standard Spring Boot datasource binding if needed +- Evaluate whether the data-tools integration test can also move to `@ServiceConnection`, or whether it should stay as-is because it is not a full application sample. + +### Recommendation + +- Prefer updating the sample test for sure, because that is the clearest fit for `@ServiceConnection`. +- Treat the integration test as optional for the same refactor if it remains clean and low-risk. + +### Verification + +- Re-run the sample and data-tools tests after the refactor to confirm Testcontainers startup and datasource binding still work. + +## 4. Address the SQL builder design comment deliberately + +### Problem + +The reviewer questioned whether the starter should use string SQL builders at all, and suggested something more `JdbcClient`-native. + +### Why This Needs Care + +- The current starter API is intentionally built around Oracle SQL fragment generation for `JdbcClient` / `JdbcTemplate`. +- Replacing raw SQL fragments with a more structured API could become a larger design change than the other PR comments. +- This area likely needs explicit agreement before implementation because it changes the public API shape and the starter’s core design. + +### Plan + +- First, evaluate feasible alternatives that still fit Spring JDBC idioms: + - wrapper helpers around `JdbcClient.StatementSpec` + - predicate/projection helper objects instead of raw strings + - a small composable SQL fragment type owned by the starter +- Compare each option against the current API on: + - simplicity + - readability in user code + - compatibility with plain `JdbcClient` + - migration cost for this PR +- Decide whether: + - to redesign in this PR, or + - to keep the current string-fragment API and open a follow-up design issue for a v2 API + +### Recommendation + +- Do not immediately redesign the starter in the same pass as the other three review comments. +- Prepare a short decision note after the evaluation: + - if a low-risk `JdbcClient`-native improvement exists, implement it + - otherwise respond in the PR that the current API is intentionally SQL-fragment based and propose a follow-up issue for a more structured v2 design + +### Deliverable + +- A short written recommendation before code changes in this area, so we do not destabilize a nearly-merge-ready branch without clear agreement. + +## Proposed Execution Order + +1. Remove `.ai` files from the PR. +2. Upgrade the container image to `23.26.1`. +3. Refactor the sample test to use `@ServiceConnection`, and optionally the integration test if it stays clean. +4. Re-run the spatial tests. +5. Separately evaluate the SQL-builder design comment and decide whether it belongs in this PR or a follow-up issue. + +## Expected File Areas + +- `.ai/` +- `database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialIntegrationTest.java` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/SpatialSampleApplicationTest.java` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/resources/application.yaml` +- Possibly other sample/test config files if `@ServiceConnection` requires standard datasource property wiring + +## Success Criteria + +- The PR no longer includes stray planning files. +- Spatial tests use `23.26.1`. +- At least the sample test uses `@ServiceConnection` cleanly. +- We have an explicit, review-ready response for the SQL-builder design comment rather than ignoring it. diff --git a/.ai/spatial-starter-redesign-pm-review-followup-plan.md b/.ai/spatial-starter-redesign-pm-review-followup-plan.md new file mode 100644 index 00000000..3e84a957 --- /dev/null +++ b/.ai/spatial-starter-redesign-pm-review-followup-plan.md @@ -0,0 +1,165 @@ +# Oracle Spatial Starter — PM Review Follow-Up Plan + +## Purpose + +This plan captures the actionable follow-up work from the PM review in `.ai/spatial-starter-redesign-pm-review.md`. + +This is a planning document only. No implementation should begin until we review and agree on the scope. + +## Overall Assessment + +I agree with the PM review's main conclusion: + +- the redesign has solved the core architectural concern +- the new API is meaningfully more Spring-native +- only a small amount of follow-up work looks necessary before ship + +I do **not** think the remaining `bind()` ergonomics concern should block this PR. That is a valid usability observation, but it is better treated as a v2 design improvement than a late redesign within the current PR. + +Likewise, I do **not** think `SDO_GEOM.SDO_BUFFER` should be pulled into this PR. It is a legitimate gap, but it is feature expansion rather than cleanup of the approved redesign. + +## Recommended Scope + +## Ship Now + +### 1. Fix invalid `mask` handling in the sample app + +The current sample implementation converts the incoming request string to: + +```java +SpatialRelationMask.valueOf(mask.trim().toUpperCase(Locale.ROOT)) +``` + +If the caller passes an unsupported value such as `INTERSECTS`, the sample will currently fail with an `IllegalArgumentException`, which surfaces as an HTTP 500. + +This is not a starter defect, but it is a sample defect, and the sample is a primary usage example for the new API. + +#### Proposed change + +- keep the request contract unchanged: `WithinLandmarkRequest` should continue to accept `String mask` +- validate and translate the string inside the sample application +- return a client error (`400 Bad Request`) instead of a server error for invalid masks +- provide a short, clear error message listing the supported values or at least naming the invalid mask + +#### Candidate implementation options + +Option A: catch and translate inside `LandmarkService` + +- update `resolveMask()` to throw a sample-specific exception with a helpful message +- add a controller-level exception handler or a global `@RestControllerAdvice` + +Option B: validate in the controller + +- parse the request mask before calling the service +- return `400` from the web layer directly + +#### Recommendation + +Use **Option A** with a small sample-specific exception plus `@RestControllerAdvice`. + +Why: + +- keeps the controller simple +- keeps mask parsing logic in one place +- produces a cleaner example of how application code can adapt starter enums to public HTTP contracts + +### 2. Add a documentation note about generated bind names + +`OracleSpatialJdbcOperations` generates bind names like: + +- `spatialGeometry1` +- `spatialGeometry2` +- `spatialGeometry3` + +This is functionally correct, but developers inspecting SQL logs may be surprised that the names increment across requests and are not tied to their domain field names. + +#### Proposed change + +Add a brief note to the spatial documentation explaining that: + +- bind names are generated internally by the starter +- they are intentionally opaque +- they may increment over the lifetime of the bean +- callers should not depend on specific bind parameter names + +#### Documentation targets + +- `site/docs/database/spatial.md` +- optionally `database/starters/oracle-spring-boot-starter-spatial/README.md` if we want the shorter README to mention it too + +#### Recommendation + +At minimum, add the note to `site/docs/database/spatial.md`. + +## Track For V2 + +### 3. Improve the `bind()` ergonomics + +The PM review is right that this usage pattern is still a bit leaky: + +```java +spatial.bind(statement, distanceExpression, withinDistance) +``` + +A caller can forget to pass one of the parts to `bind()` even though they already used its SQL fragment in the statement string. + +#### Why I would not change this now + +- the current design is still a major improvement over the raw string-builder API +- a good fix likely needs a more opinionated rendering/binding model +- changing this now risks destabilising the approved redesign + +#### V2 exploration candidates + +- query-part rendering objects that contribute SQL and bindings together in one step +- helper APIs that build a whole `WHERE` or `SELECT` clause from typed parts +- a tighter `JdbcClient` integration that reduces the chance of forgetting bind contributors + +### 4. Add `SDO_GEOM.SDO_BUFFER` + +This remains a good enhancement target for a future iteration. + +#### Why I would not add it now + +- it is feature expansion, not review cleanup +- it would widen the API surface late in the PR +- it deserves its own sample and docs once added + +#### V2 scope candidate + +- add a buffer expression method on `OracleSpatialJdbcOperations` +- document how to combine buffer generation with `filter`, `relate`, or distance-based searches +- add at least one focused sample/test case + +## Proposed Implementation Sequence + +If we decide to proceed with the ship-now items, I would implement them in this order: + +1. Add sample-level invalid-mask exception handling and map it to HTTP 400. +2. Add or update a sample test covering an invalid `mask` request. +3. Add the bind-name explanation to the docs. +4. Do a final doc pass to ensure the sample behavior described in docs still matches the implementation. + +## Expected Files To Touch + +Likely code/docs files: + +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkService.java` +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkController.java` or a new advice/exception file in the same package +- `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/SpatialSampleApplicationTest.java` +- `site/docs/database/spatial.md` +- optionally `database/starters/oracle-spring-boot-starter-spatial/README.md` + +## Recommendation + +Proceed with only the two pre-ship cleanup items: + +- sample invalid-mask handling +- bind-name documentation note + +Defer: + +- `bind()` ergonomics redesign +- `SDO_GEOM.SDO_BUFFER` + +That keeps this PR disciplined while still addressing the one genuine release-quality issue identified in the PM review. diff --git a/.ai/spatial-starter-redesign-pm-review.md b/.ai/spatial-starter-redesign-pm-review.md new file mode 100644 index 00000000..d76f5148 --- /dev/null +++ b/.ai/spatial-starter-redesign-pm-review.md @@ -0,0 +1,73 @@ +# PM Review: Redesigned Oracle Spring Boot Spatial Starter + +*Review date: 2026-04-01* +*Based on: oracle-spatial-jdbc-idiomatic-redesign-plan.md (approved)* + +--- + +## Overall Assessment + +The redesign successfully addresses the reviewer feedback. The API is now meaningfully Spring-native rather than a raw string utility. The core question — *does this serve spatial customers well and showcase the right capabilities?* — is answered more convincingly than before. + +--- + +## What the Redesign Gets Right + +**The reviewer's concern is resolved.** The API is no longer a string concatenation utility. Developers inject one bean, work with typed objects (`SpatialGeometry`, `SpatialPredicate`, `SpatialExpression`), and the `bind()` method integrates directly with `JdbcClient.StatementSpec`. This is recognisably Spring. + +**`SpatialRelationMask` enum is a genuine improvement.** It directly fixes the risk flagged in the v1 PM review — a developer can no longer pass `"INTERSECTS"` and get a silent runtime error from Oracle. The enum is self-documenting and covers the full set of valid masks including `COVERS`. + +**`SDO_GEOM.SDO_DISTANCE` is now a first-class operation.** The `distance()` method on `OracleSpatialJdbcOperations` closes one of the top gaps from the v1 review. Customers building "find nearest N with distance in result" queries — the most common spatial use case — have what they need. + +**The sample is meaningfully better.** `LandmarkService` reads like idiomatic Spring code. The `findNear` method in particular is clean: geometry, distance expression, and predicate are all named variables before the SQL is assembled. A developer reading this will understand the pattern immediately. + +**`geoJsonRowMapper` is a proper Spring JDBC integration hook.** Aligns with the JSON data-tools pattern called out in the redesign plan. + +**Documentation has kept pace.** The "What You Inject vs What You Build" section in `spatial.md` is the right mental model explanation. The four query pattern examples are concrete and directly usable. + +--- + +## Remaining Concerns + +### 1. The `bind()` pattern is still somewhat leaky (v2 candidate) + +The developer still calls `.expression()` or `.clause()` inline inside the SQL string, and passes the same objects to `bind()` separately. Example from `findNear`: + +```java +spatial.bind( + jdbcClient.sql("select ... " + distanceExpression.selection("distance") + + " from landmarks where " + withinDistance.clause() + ...), + distanceExpression, withinDistance) +``` + +There is a subtle risk: a developer creates a `SpatialPredicate` but forgets to include it in the `bind()` varargs, and their bind parameter goes unset. The types do not prevent this. Acceptable for v1, but worth tracking as a v2 usability gap. + +### 2. `resolveMask()` in the sample throws an unhandled exception — fix before ship + +`SpatialRelationMask.valueOf(mask.trim().toUpperCase())` in `LandmarkService` will throw `IllegalArgumentException` if the API caller passes an unsupported string like `"INTERSECTS"`. This surfaces as a 500 in the sample app rather than a 400. The sample is the canonical usage example, so this should be fixed before release. (Sample issue, not a starter issue.) + +### 3. `SDO_GEOM.SDO_BUFFER` still absent (carry forward from v1 review) + +Buffer generation was flagged as a high-priority gap in the v1 PM review. A customer who wants "find all properties within 500m of a railway line" needs a buffer expression. Not in scope for this redesign — track for v2. + +### 4. Bind-name sequencer worth a documentation note (minor) + +`OracleSpatialJdbcOperations` uses an `AtomicLong` to generate unique bind names (`spatialGeometry1`, `spatialGeometry2`, etc.). Developers who inspect generated SQL in logs may be confused by incrementing parameter names across requests. Functionally fine, but a one-line note in the usage docs would head off confusion. + +--- + +## Summary + +| Area | v1 | Redesign | +|---|---|---| +| Spring-native API | No | Yes | +| `SDO_RELATE` mask safety | Risk (raw string) | Fixed (enum) | +| `SDO_GEOM.SDO_DISTANCE` | Missing | Added | +| `RowMapper` support | Missing | Added | +| Sample readability | Acceptable | Good | +| Documentation quality | Good | Very good | +| `bind()` completeness risk | N/A | Minor — developer can omit parts | +| `SDO_GEOM.SDO_BUFFER` | Missing | Still missing | +| Sample error handling | N/A | Needs attention (500 on bad mask) | + +**Recommendation:** Approve with the `resolveMask` error handling fixed in the sample. Remaining concerns are either v2 items or minor documentation additions. diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/database/starters/README.md b/database/starters/README.md index 9cc8c44c..89ea9490 100644 --- a/database/starters/README.md +++ b/database/starters/README.md @@ -10,6 +10,6 @@ The following starters are provided: | [Oracle Spring Boot Starter AQJMS](oracle-spring-boot-starter-aqjms) | Autoconfigure Oracle Database AQJMS Connections. | | [Oracle Spring Boot Starter Wallet](oracle-spring-boot-starter-wallet) | Bundle dependencies for Oracle Wallet. | | [Oracle Spring Boot Starter JSON Collections](oracle-spring-boot-starter-json-collections) | Autoconfiguration and utilities for JSON with Oracle Database | +| [Oracle Spring Boot Starter Spatial](oracle-spring-boot-starter-spatial) | Autoconfiguration and helper utilities for Oracle Spatial with GeoJSON-first APIs | | [Oracle Spring Boot Starter for the Kafka Java Client for Oracle Database Transactional Event Queues](oracle-spring-boot-starter-okafka) | Autoconfiguration for Kafka Java Client for Oracle Transactional Event Queues | - diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/pom.xml b/database/starters/oracle-spring-boot-spatial-data-tools/pom.xml new file mode 100644 index 00000000..deb19550 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/pom.xml @@ -0,0 +1,91 @@ + + + + + 4.0.0 + + oracle-spring-boot-starters + com.oracle.database.spring + 26.1.0 + ../pom.xml + + + oracle-spring-boot-spatial-data-tools + 26.1.0 + + Oracle Spring Boot - Spatial Data Tools + Spring Boot for Oracle Database Spatial Data Tools + https://github.com/oracle/spring-cloud-oracle/tree/main/database/starters/oracle-spring-boot-spatial-data-tools + + + Oracle America, Inc. + https://www.oracle.com + + + + + Oracle + obaas_ww at oracle.com + Oracle America, Inc. + https://www.oracle.com + + + + + + The Universal Permissive License (UPL), Version 1.0 + https://oss.oracle.com/licenses/upl/ + repo + + + + + https://github.com/oracle/spring-cloud-oracle + scm:git:https://github.com/oracle/spring-cloud-oracle.git + scm:git:git@github.com:oracle/spring-cloud-oracle.git + + + + + com.oracle.database.spring + oracle-spring-boot-starter-ucp + ${project.version} + + + org.springframework.boot + spring-boot-configuration-processor + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-oracle-free + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/AbstractSpatialJdbcPart.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/AbstractSpatialJdbcPart.java new file mode 100644 index 00000000..d8e212bf --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/AbstractSpatialJdbcPart.java @@ -0,0 +1,24 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.util.Map; + +import org.springframework.jdbc.core.simple.JdbcClient; + +abstract class AbstractSpatialJdbcPart implements SpatialJdbcBindable { + private final Map parameters; + + AbstractSpatialJdbcPart(Map parameters) { + this.parameters = parameters; + } + + @Override + public JdbcClient.StatementSpec bind(JdbcClient.StatementSpec statement) { + JdbcClient.StatementSpec current = statement; + for (Map.Entry entry : parameters.entrySet()) { + current = current.param(entry.getKey(), entry.getValue()); + } + return current; + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialAutoConfiguration.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialAutoConfiguration.java new file mode 100644 index 00000000..1814b11c --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialAutoConfiguration.java @@ -0,0 +1,28 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import javax.sql.DataSource; + +import oracle.jdbc.OracleConnection; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration(after = DataSourceAutoConfiguration.class) +@ConditionalOnClass(OracleConnection.class) +@ConditionalOnBean(DataSource.class) +@ConditionalOnProperty(prefix = OracleSpatialProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(OracleSpatialProperties.class) +public class OracleSpatialAutoConfiguration { + @Bean + @ConditionalOnMissingBean + OracleSpatialJdbcOperations oracleSpatialJdbcOperations(OracleSpatialProperties properties) { + return new OracleSpatialJdbcOperations(properties); + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialGeoJsonRowMapper.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialGeoJsonRowMapper.java new file mode 100644 index 00000000..59235669 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialGeoJsonRowMapper.java @@ -0,0 +1,32 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.util.Assert; + +/** + * {@link RowMapper} that reads a projected GeoJSON column from a JDBC result + * set. + */ +public final class OracleSpatialGeoJsonRowMapper implements RowMapper { + private final String columnLabel; + + /** + * Creates a row mapper for the given projected GeoJSON column label. + * + * @param columnLabel result-set column label + */ + public OracleSpatialGeoJsonRowMapper(String columnLabel) { + Assert.hasText(columnLabel, "columnLabel must not be blank"); + this.columnLabel = columnLabel; + } + + @Override + public String mapRow(ResultSet rs, int rowNum) throws SQLException { + return rs.getString(columnLabel); + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialJdbcOperations.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialJdbcOperations.java new file mode 100644 index 00000000..3580a808 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialJdbcOperations.java @@ -0,0 +1,248 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.util.Assert; + +/** + * Spring JDBC-oriented entry point for working with Oracle Spatial and + * GeoJSON-backed {@code SDO_GEOMETRY} values. + */ +public class OracleSpatialJdbcOperations { + private final OracleSpatialProperties properties; + private final AtomicLong bindSequence = new AtomicLong(); + + /** + * Creates a new operations helper backed by the provided properties. + * + * @param properties spatial defaults used for SRID and distance units + */ + public OracleSpatialJdbcOperations(OracleSpatialProperties properties) { + this.properties = properties; + } + + /** + * Creates a bindable geometry value using the configured default SRID. + * + * @param geoJson GeoJSON payload + * @return spatial geometry wrapper + */ + public SpatialGeometry geometry(String geoJson) { + return geometry(geoJson, properties.getDefaultSrid()); + } + + /** + * Creates a bindable geometry value using the supplied SRID. + * + * @param geoJson GeoJSON payload + * @param srid Oracle Spatial SRID + * @return spatial geometry wrapper + */ + public SpatialGeometry geometry(String geoJson, int srid) { + Assert.hasText(geoJson, "geoJson must not be blank"); + Assert.isTrue(srid > 0, "srid must be greater than 0"); + return new SpatialGeometry("spatialGeometry" + bindSequence.incrementAndGet(), geoJson, srid); + } + + /** + * Returns an expression that converts a bindable GeoJSON geometry into + * {@code SDO_GEOMETRY}. This is useful for inserts, updates, and advanced + * custom SQL. + * + * @param geometry geometry value to convert + * @return bindable SQL expression + */ + public SpatialExpression fromGeoJson(SpatialGeometry geometry) { + Assert.notNull(geometry, "geometry must not be null"); + return expression("SDO_UTIL.FROM_GEOJSON(:" + geometry.bindName() + ", null, " + geometry.srid() + ")", geometry); + } + + /** + * Returns a GeoJSON projection expression for the given geometry column. + * + * @param geometryColumn geometry column or expression + * @return SQL expression suitable for a select list + */ + public SpatialExpression toGeoJson(String geometryColumn) { + Assert.hasText(geometryColumn, "geometryColumn must not be blank"); + return expression("SDO_UTIL.TO_GEOJSON(" + geometryColumn + ")"); + } + + /** + * Returns an {@code SDO_FILTER(...)} predicate. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @return bindable spatial predicate + */ + public SpatialPredicate filter(String geometryColumn, SpatialGeometry geometry) { + return predicate("SDO_FILTER(" + geometryColumn + ", " + fromGeoJsonSql(geometry) + ") = 'TRUE'", geometry); + } + + /** + * Returns an {@code SDO_RELATE(...)} predicate for the given relationship + * mask. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param mask relationship mask + * @return bindable spatial predicate + */ + public SpatialPredicate relate(String geometryColumn, SpatialGeometry geometry, SpatialRelationMask mask) { + Assert.notNull(mask, "mask must not be null"); + return predicate("SDO_RELATE(" + geometryColumn + ", " + fromGeoJsonSql(geometry) + + ", 'mask=" + mask.sqlValue() + "') = 'TRUE'", geometry); + } + + /** + * Returns an {@code SDO_WITHIN_DISTANCE(...)} predicate using the configured + * default distance unit. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param distance distance threshold + * @return bindable spatial predicate + */ + public SpatialPredicate withinDistance(String geometryColumn, SpatialGeometry geometry, Number distance) { + return withinDistance(geometryColumn, geometry, distance, properties.getDefaultDistanceUnit()); + } + + /** + * Returns an {@code SDO_WITHIN_DISTANCE(...)} predicate using the supplied + * distance unit token. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param distance distance threshold + * @param unit Oracle distance unit token + * @return bindable spatial predicate + */ + public SpatialPredicate withinDistance(String geometryColumn, SpatialGeometry geometry, Number distance, String unit) { + validateDistanceUnit(unit); + Assert.notNull(distance, "distance must not be null"); + return predicate("SDO_WITHIN_DISTANCE(" + geometryColumn + ", " + fromGeoJsonSql(geometry) + + ", '" + distanceClause(distance, unit) + "') = 'TRUE'", geometry); + } + + /** + * Returns an {@code SDO_NN(...)} predicate using Oracle operator id + * {@code 1}. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param numResults Oracle {@code sdo_num_res} value + * @return bindable spatial predicate + */ + public SpatialPredicate nearestNeighbor(String geometryColumn, SpatialGeometry geometry, int numResults) { + Assert.isTrue(numResults > 0, "numResults must be greater than 0"); + return predicate("SDO_NN(" + geometryColumn + ", " + fromGeoJsonSql(geometry) + + ", 'sdo_num_res=" + numResults + "', 1) = 'TRUE'", geometry); + } + + /** + * Returns the {@code SDO_NN_DISTANCE(1)} expression for use in select lists + * or order clauses after {@link #nearestNeighbor(String, SpatialGeometry, int)}. + * + * @return SQL expression + */ + public SpatialExpression nearestNeighborDistance() { + return expression("SDO_NN_DISTANCE(1)"); + } + + /** + * Returns a distance expression based on {@code SDO_GEOM.SDO_DISTANCE} + * using the configured default unit and a caller-provided tolerance. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param tolerance Oracle Spatial tolerance + * @return bindable SQL expression + */ + public SpatialExpression distance(String geometryColumn, SpatialGeometry geometry, Number tolerance) { + return distance(geometryColumn, geometry, tolerance, properties.getDefaultDistanceUnit()); + } + + /** + * Returns a distance expression based on {@code SDO_GEOM.SDO_DISTANCE} + * using the supplied tolerance and unit. + * + * @param geometryColumn geometry column or expression + * @param geometry bindable geometry value + * @param tolerance Oracle Spatial tolerance + * @param unit Oracle distance unit token + * @return bindable SQL expression + */ + public SpatialExpression distance(String geometryColumn, SpatialGeometry geometry, Number tolerance, String unit) { + Assert.notNull(tolerance, "tolerance must not be null"); + validateDistanceUnit(unit); + return expression("SDO_GEOM.SDO_DISTANCE(" + geometryColumn + ", " + + fromGeoJsonSql(geometry) + ", " + tolerance + ", 'unit=" + unit.trim() + "')", geometry); + } + + /** + * Returns a Spring JDBC {@link RowMapper} that reads a GeoJSON projection + * column from a result set. + * + * @param columnLabel result-set column label + * @return row mapper for GeoJSON string results + */ + public RowMapper geoJsonRowMapper(String columnLabel) { + return new OracleSpatialGeoJsonRowMapper(columnLabel); + } + + /** + * Applies bind parameters from the given spatial parts to a + * {@link JdbcClient.StatementSpec}. + * + * @param statement JDBC statement spec + * @param parts spatial parts carrying bind values + * @return updated statement + */ + public JdbcClient.StatementSpec bind(JdbcClient.StatementSpec statement, SpatialJdbcBindable... parts) { + JdbcClient.StatementSpec current = statement; + for (SpatialJdbcBindable part : parts) { + if (part != null) { + current = part.bind(current); + } + } + return current; + } + + private SpatialExpression expression(String expression) { + return new SpatialExpression(expression, Map.of()); + } + + private SpatialExpression expression(String expression, SpatialGeometry geometry) { + return new SpatialExpression(expression, parameters(geometry)); + } + + private SpatialPredicate predicate(String clause, SpatialGeometry geometry) { + return new SpatialPredicate(clause, parameters(geometry)); + } + + private Map parameters(SpatialGeometry geometry) { + LinkedHashMap parameters = new LinkedHashMap<>(); + parameters.put(geometry.bindName(), geometry.geoJson()); + return parameters; + } + + private String fromGeoJsonSql(SpatialGeometry geometry) { + Assert.notNull(geometry, "geometry must not be null"); + return "SDO_UTIL.FROM_GEOJSON(:" + geometry.bindName() + ", null, " + geometry.srid() + ")"; + } + + private String distanceClause(Number distance, String unit) { + return "distance=" + distance + " unit=" + unit.trim(); + } + + private void validateDistanceUnit(String unit) { + Assert.hasText(unit, "unit must not be blank"); + Assert.isTrue(!unit.trim().contains("'"), "unit must not contain single quotes"); + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialProperties.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialProperties.java new file mode 100644 index 00000000..0f26fcac --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/OracleSpatialProperties.java @@ -0,0 +1,64 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.Assert; + +/** + * Configuration properties for Oracle Spatial helper beans. + */ +@ConfigurationProperties(prefix = OracleSpatialProperties.PREFIX) +public class OracleSpatialProperties { + /** + * Property prefix for Oracle Spatial starter configuration. + */ + public static final String PREFIX = "oracle.database.spatial"; + + /** + * Enables or disables Oracle Spatial auto-configuration. + */ + private boolean enabled = true; + + /** + * Default SRID used when converting GeoJSON values into {@code SDO_GEOMETRY}. + * The value must be a positive Oracle Spatial SRID such as {@code 4326}. + */ + private int defaultSrid = 4326; + + /** + * Default distance unit appended to generated distance clauses. + * This value is intentionally validated loosely so Oracle-supported formats + * such as {@code M}, {@code KM}, or {@code UNIT=MILE} remain usable. + */ + private String defaultDistanceUnit = "M"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getDefaultSrid() { + return defaultSrid; + } + + public void setDefaultSrid(int defaultSrid) { + Assert.isTrue(defaultSrid > 0, "oracle.database.spatial.default-srid must be greater than 0"); + this.defaultSrid = defaultSrid; + } + + public String getDefaultDistanceUnit() { + return defaultDistanceUnit; + } + + public void setDefaultDistanceUnit(String defaultDistanceUnit) { + Assert.hasText(defaultDistanceUnit, "oracle.database.spatial.default-distance-unit must not be blank"); + String trimmed = defaultDistanceUnit.trim(); + Assert.isTrue(!trimmed.contains("'"), + "oracle.database.spatial.default-distance-unit must not contain single quotes"); + this.defaultDistanceUnit = trimmed; + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialExpression.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialExpression.java new file mode 100644 index 00000000..6d85a638 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialExpression.java @@ -0,0 +1,42 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * Represents a spatial SQL expression that can be used in a select list, + * insert/update value expression, or order clause while still carrying any + * required JDBC bind values. + */ +public final class SpatialExpression extends AbstractSpatialJdbcPart { + private final String expression; + + SpatialExpression(String expression, Map parameters) { + super(parameters); + Assert.hasText(expression, "expression must not be blank"); + this.expression = expression; + } + + /** + * Returns the raw SQL expression. + * + * @return SQL expression + */ + public String expression() { + return expression; + } + + /** + * Returns this expression as a select-list projection with the given alias. + * + * @param alias projection alias + * @return SQL select-list entry + */ + public String selection(String alias) { + Assert.hasText(alias, "alias must not be blank"); + return expression + " " + alias; + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialGeometry.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialGeometry.java new file mode 100644 index 00000000..97d7952e --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialGeometry.java @@ -0,0 +1,46 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import org.springframework.util.Assert; + +/** + * Represents a GeoJSON value and SRID that can be bound into Oracle Spatial + * SQL generated by {@link OracleSpatialJdbcOperations}. + */ +public final class SpatialGeometry { + private final String bindName; + private final String geoJson; + private final int srid; + + SpatialGeometry(String bindName, String geoJson, int srid) { + Assert.hasText(bindName, "bindName must not be blank"); + Assert.hasText(geoJson, "geoJson must not be blank"); + Assert.isTrue(srid > 0, "srid must be greater than 0"); + this.bindName = bindName; + this.geoJson = geoJson; + this.srid = srid; + } + + String bindName() { + return bindName; + } + + /** + * Returns the original GeoJSON payload. + * + * @return GeoJSON value + */ + public String geoJson() { + return geoJson; + } + + /** + * Returns the SRID associated with this geometry. + * + * @return Oracle Spatial SRID + */ + public int srid() { + return srid; + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialJdbcBindable.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialJdbcBindable.java new file mode 100644 index 00000000..ab7ab915 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialJdbcBindable.java @@ -0,0 +1,19 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * Contract for spatial query parts that can apply their bind parameters to a + * {@link JdbcClient.StatementSpec}. + */ +public interface SpatialJdbcBindable { + /** + * Applies this part's bind parameters to the given statement. + * + * @param statement statement to update + * @return updated statement + */ + JdbcClient.StatementSpec bind(JdbcClient.StatementSpec statement); +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialPredicate.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialPredicate.java new file mode 100644 index 00000000..34e338d2 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialPredicate.java @@ -0,0 +1,30 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * Represents a spatial predicate suitable for use in a SQL {@code WHERE} + * clause while carrying the bind values needed by Spring JDBC. + */ +public final class SpatialPredicate extends AbstractSpatialJdbcPart { + private final String clause; + + SpatialPredicate(String clause, Map parameters) { + super(parameters); + Assert.hasText(clause, "clause must not be blank"); + this.clause = clause; + } + + /** + * Returns the SQL predicate clause. + * + * @return SQL predicate + */ + public String clause() { + return clause; + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialRelationMask.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialRelationMask.java new file mode 100644 index 00000000..8ebc1178 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/java/com/oracle/spring/spatial/SpatialRelationMask.java @@ -0,0 +1,24 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +/** + * Supported Oracle Spatial relationship masks for {@code SDO_RELATE}. + */ +public enum SpatialRelationMask { + ANYINTERACT, + CONTAINS, + COVEREDBY, + COVERS, + DISJOINT, + EQUAL, + INSIDE, + ON, + OVERLAPBDYDISJOINT, + OVERLAPBDYINTERSECT, + TOUCH; + + String sqlValue() { + return name(); + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring.factories b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..8a4d28db --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.oracle.spring.spatial.OracleSpatialAutoConfiguration diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..0621486c --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.oracle.spring.spatial.OracleSpatialAutoConfiguration diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/Config.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/Config.java new file mode 100644 index 00000000..11c1f566 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/Config.java @@ -0,0 +1,17 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import javax.sql.DataSource; + +import oracle.ucp.jdbc.PoolDataSourceFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class Config { + @Bean + DataSource dataSource() { + return PoolDataSourceFactory.getPoolDataSource(); + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialAutoConfigurationTest.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialAutoConfigurationTest.java new file mode 100644 index 00000000..395ad7c6 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialAutoConfigurationTest.java @@ -0,0 +1,43 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = OracleSpatialAutoConfigurationTest.TestApplication.class) +public class OracleSpatialAutoConfigurationTest { + @Autowired + OracleSpatialJdbcOperations spatial; + + @Autowired + OracleSpatialProperties properties; + + @Test + void spatialBeansConfigured() { + SpatialGeometry geometry = spatial.geometry("{\"type\":\"Point\",\"coordinates\":[-122.3933,37.7955]}"); + SpatialExpression fromGeoJson = spatial.fromGeoJson(geometry); + SpatialPredicate withinDistance = spatial.withinDistance("geometry", geometry, 500); + + assertThat(spatial).isNotNull(); + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getDefaultSrid()).isEqualTo(4326); + assertThat(properties.getDefaultDistanceUnit()).isEqualTo("M"); + assertThat(fromGeoJson.expression()).isEqualTo("SDO_UTIL.FROM_GEOJSON(:spatialGeometry1, null, 4326)"); + assertThat(withinDistance.clause()) + .contains("SDO_WITHIN_DISTANCE") + .contains("distance=500 unit=M"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(Config.class) + static class TestApplication { + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialConditionalAutoConfigurationTest.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialConditionalAutoConfigurationTest.java new file mode 100644 index 00000000..605373f0 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialConditionalAutoConfigurationTest.java @@ -0,0 +1,46 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import javax.sql.DataSource; + +import oracle.ucp.jdbc.PoolDataSourceFactory; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OracleSpatialConditionalAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OracleSpatialAutoConfiguration.class)); + + @Test + void backsOffWhenNoDataSourceBeanIsPresent() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(OracleSpatialJdbcOperations.class); + }); + } + + @Test + void backsOffWhenOracleJdbcClassesAreMissing() { + contextRunner + .withUserConfiguration(TestDataSourceConfiguration.class) + .withClassLoader(new FilteredClassLoader(oracle.jdbc.OracleConnection.class)) + .run(context -> { + assertThat(context).hasSingleBean(DataSource.class); + assertThat(context).doesNotHaveBean(OracleSpatialJdbcOperations.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class TestDataSourceConfiguration { + @Bean + DataSource dataSource() { + return PoolDataSourceFactory.getPoolDataSource(); + } + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialDisabledAutoConfigurationTest.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialDisabledAutoConfigurationTest.java new file mode 100644 index 00000000..61e2827f --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialDisabledAutoConfigurationTest.java @@ -0,0 +1,32 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + classes = OracleSpatialDisabledAutoConfigurationTest.TestApplication.class, + properties = "oracle.database.spatial.enabled=false" +) +public class OracleSpatialDisabledAutoConfigurationTest { + @Autowired(required = false) + OracleSpatialJdbcOperations spatial; + + @Test + void spatialBeansDisabled() { + assertThat(spatial).isNull(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(Config.class) + static class TestApplication { + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialIntegrationTest.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialIntegrationTest.java new file mode 100644 index 00000000..19d58c36 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialIntegrationTest.java @@ -0,0 +1,113 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.context.jdbc.Sql; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.oracle.OracleContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@SpringBootTest(classes = OracleSpatialIntegrationTest.TestApplication.class) +@Sql(scripts = "/spatial-init.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +public class OracleSpatialIntegrationTest { + @Container + @ServiceConnection + static OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-free:23.26.1-full-faststart") + .withStartupTimeout(Duration.ofMinutes(2)) + .withUsername("testuser") + .withPassword("testpwd"); + + @Autowired + OracleSpatialJdbcOperations spatial; + + @Autowired + JdbcClient jdbcClient; + + @Test + void geoJsonRoundTrip() { + SpatialExpression geometry = spatial.toGeoJson("geometry"); + String geoJson = jdbcClient.sql("select " + geometry.selection("geometry") + " from landmarks where id = :id") + .param("id", 1L) + .query(spatial.geoJsonRowMapper("geometry")) + .single(); + + assertThat(geoJson).contains("\"Point\""); + assertThat(geoJson).contains("-122.3933"); + } + + @Test + void spatialPredicatesWork() { + String point = "{\"type\":\"Point\",\"coordinates\":[-122.4194,37.7749]}"; + String polygon = "{\"type\":\"Polygon\",\"coordinates\":[[[-122.53,37.70],[-122.35,37.70],[-122.35,37.83],[-122.53,37.83],[-122.53,37.70]]]}"; + SpatialGeometry pointGeometry = spatial.geometry(point); + SpatialGeometry polygonGeometry = spatial.geometry(polygon); + SpatialPredicate filter = spatial.filter("geometry", polygonGeometry); + SpatialPredicate relate = spatial.relate("geometry", polygonGeometry, SpatialRelationMask.ANYINTERACT); + SpatialPredicate withinDistance = spatial.withinDistance("geometry", pointGeometry, 2000); + SpatialPredicate nearestNeighbor = spatial.nearestNeighbor("geometry", pointGeometry, 1); + + Long filterCount = spatial.bind( + jdbcClient.sql("select count(*) from landmarks where " + filter.clause()), + filter) + .query(Long.class) + .single(); + assertThat(filterCount).isGreaterThanOrEqualTo(2L); + + Long relateCount = spatial.bind( + jdbcClient.sql("select count(*) from landmarks where " + relate.clause()), + relate) + .query(Long.class) + .single(); + assertThat(relateCount).isGreaterThanOrEqualTo(2L); + + Long withinDistanceCount = spatial.bind( + jdbcClient.sql("select count(*) from landmarks where " + withinDistance.clause()), + withinDistance) + .query(Long.class) + .single(); + assertThat(withinDistanceCount).isGreaterThanOrEqualTo(1L); + + String nearestName = spatial.bind( + jdbcClient.sql("select name from landmarks where " + + nearestNeighbor.clause() + + " order by " + spatial.nearestNeighborDistance().expression()), + nearestNeighbor) + .query(String.class) + .single(); + assertThat(nearestName).isEqualTo("Union Square"); + } + + @Test + void distanceExpressionWorks() { + String point = "{\"type\":\"Point\",\"coordinates\":[-122.4194,37.7749]}"; + SpatialGeometry pointGeometry = spatial.geometry(point); + SpatialExpression distance = spatial.distance("geometry", pointGeometry, 0.005); + + Double nearestDistance = spatial.bind( + jdbcClient.sql("select " + distance.selection("distance") + + " from landmarks where id = :id"), + distance) + .param("id", 2L) + .query(Double.class) + .single(); + + assertThat(nearestDistance).isGreaterThanOrEqualTo(0.0d); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApplication { + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialOverrideAutoConfigurationTest.java b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialOverrideAutoConfigurationTest.java new file mode 100644 index 00000000..5030fffe --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/java/com/oracle/spring/spatial/OracleSpatialOverrideAutoConfigurationTest.java @@ -0,0 +1,51 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.spring.spatial; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = OracleSpatialOverrideAutoConfigurationTest.TestApplication.class) +public class OracleSpatialOverrideAutoConfigurationTest { + @Autowired + OracleSpatialJdbcOperations spatial; + + @Test + void userBeansTakePrecedence() { + SpatialGeometry geometry = spatial.geometry("{\"type\":\"Point\",\"coordinates\":[-122.3893,37.7786]}"); + + assertThat(geometry.srid()).isEqualTo(3857); + assertThat(spatial.toGeoJson("geometry").expression()).isEqualTo("custom"); + } + + @TestConfiguration + static class OverrideBeans { + @Bean + OracleSpatialJdbcOperations oracleSpatialJdbcOperations() { + OracleSpatialProperties properties = new OracleSpatialProperties(); + properties.setDefaultSrid(3857); + return new OracleSpatialJdbcOperations(properties) { + @Override + public SpatialExpression toGeoJson(String geometryColumn) { + return new SpatialExpression("custom", Map.of()); + } + }; + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({Config.class, OverrideBeans.class}) + static class TestApplication { + } +} diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/application.yaml b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/application.yaml new file mode 100644 index 00000000..458f453f --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/application.yaml @@ -0,0 +1,4 @@ +spring: + datasource: + driver-class-name: oracle.jdbc.OracleDriver + type: oracle.ucp.jdbc.PoolDataSourceImpl diff --git a/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/spatial-init.sql b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/spatial-init.sql new file mode 100644 index 00000000..826205e7 --- /dev/null +++ b/database/starters/oracle-spring-boot-spatial-data-tools/src/test/resources/spatial-init.sql @@ -0,0 +1,30 @@ +create table landmarks ( + id number primary key, + name varchar2(200) not null, + category varchar2(100) not null, + geometry mdsys.sdo_geometry not null +); + +insert into user_sdo_geom_metadata (table_name, column_name, diminfo, srid) +values ( + 'LANDMARKS', + 'GEOMETRY', + mdsys.sdo_dim_array( + mdsys.sdo_dim_element('LONG', -180, 180, 0.005), + mdsys.sdo_dim_element('LAT', -90, 90, 0.005) + ), + 4326 +); + +create index landmarks_spatial_idx +on landmarks (geometry) +indextype is mdsys.spatial_index_v2; + +insert into landmarks (id, name, category, geometry) +values (1, 'Ferry Building', 'MARKET', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3933,37.7955]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (2, 'Union Square', 'PLAZA', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.4074,37.7879]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (3, 'Golden Gate Park', 'PARK', sdo_util.from_geojson('{"type":"Polygon","coordinates":[[[-122.511,37.771],[-122.454,37.771],[-122.454,37.768],[-122.511,37.768],[-122.511,37.771]]]}', null, 4326)); diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/README.md b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/README.md new file mode 100644 index 00000000..26b42735 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/README.md @@ -0,0 +1,44 @@ +# Oracle Spring Boot Sample for Oracle Spatial + +This sample application demonstrates how to use the Oracle Spring Boot Starter for Oracle Spatial with a small REST API that stores and queries GeoJSON against `SDO_GEOMETRY`. + +The sample is intentionally focused on Oracle Spatial geometric data, not Oracle AI `VECTOR` search. + +The sample includes: + +- A `Landmark` REST API for insert and query flows +- SQL initialization for the spatial table, metadata, and index +- Spatial queries for within-distance, distance-ordered proximity search, and polygon search +- A Spring Boot integration test that runs against Oracle Free with Testcontainers + +The sample keeps spatial schema setup in SQL initialization scripts. In a production application, table DDL, `USER_SDO_GEOM_METADATA`, and spatial index creation should typically live in your migration tooling. + +## Run the sample application + +The sample application test uses Testcontainers, and creates a temporary Oracle Free container database, and requires a docker runtime environment. + +To run the test application, run the following command: + +```shell +mvn -f database/starters/pom.xml \ + -pl oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial -am \ + -Dtest=SpatialSampleApplicationTest \ + -Dsurefire.failIfNoSpecifiedTests=false \ + test +``` + +## Sample API Notes + +- `GET /landmarks/near` accepts the `geometry` query parameter as compact GeoJSON on a single line. +- The sample does not add controller validation annotations; production applications should validate request payloads and query parameters before constructing spatial SQL. + +## Configure your project to use Oracle Spatial + +To use Oracle Spatial from your Spring Boot application, add the following Maven dependency to your project: + +```xml + + com.oracle.database.spring + oracle-spring-boot-starter-spatial + +``` diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/pom.xml b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/pom.xml new file mode 100644 index 00000000..03149374 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/pom.xml @@ -0,0 +1,94 @@ + + + + + 4.0.0 + + oracle-spring-boot-starter-samples + com.oracle.database.spring + 26.1.0 + ../pom.xml + + + oracle-spring-boot-sample-spatial + 26.1.0 + + Oracle Spring Boot Starter - Spatial Sample + Oracle Spring Boot Starter Sample for Oracle Spatial + + + Oracle America, Inc. + https://www.oracle.com + + + + + Oracle + obaas_ww at oracle.com + Oracle America, Inc. + https://www.oracle.com + + + + + + The Universal Permissive License (UPL), Version 1.0 + https://oss.oracle.com/licenses/upl/ + repo + + + + + https://github.com/oracle/spring-cloud-oracle + scm:git:https://github.com/oracle/spring-cloud-oracle.git + scm:git:git@github.com:oracle/spring-cloud-oracle.git + + + + + com.oracle.database.spring + oracle-spring-boot-starter-spatial + ${project.version} + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-oracle-free + test + + + diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/InvalidSpatialMaskException.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/InvalidSpatialMaskException.java new file mode 100644 index 00000000..a1b46ec6 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/InvalidSpatialMaskException.java @@ -0,0 +1,10 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +final class InvalidSpatialMaskException extends RuntimeException { + InvalidSpatialMaskException(String mask, String supportedValues) { + super("Unsupported spatial relation mask '%s'. Supported values: %s" + .formatted(mask, supportedValues)); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/Landmark.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/Landmark.java new file mode 100644 index 00000000..c7c4a306 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/Landmark.java @@ -0,0 +1,6 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +public record Landmark(Long id, String name, String category, String geometry) { +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkController.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkController.java new file mode 100644 index 00000000..6963a4dd --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkController.java @@ -0,0 +1,43 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class LandmarkController { + private final LandmarkService landmarkService; + + public LandmarkController(LandmarkService landmarkService) { + this.landmarkService = landmarkService; + } + + @PostMapping("/landmarks") + public Landmark create(@RequestBody Landmark landmark) { + return landmarkService.create(landmark); + } + + @GetMapping("/landmarks/{id}") + public Landmark getById(@PathVariable Long id) { + return landmarkService.getById(id); + } + + @GetMapping("/landmarks/near") + public List near(@RequestParam String geometry, + @RequestParam(required = false) Integer distance, + @RequestParam(required = false) Integer limit) { + return landmarkService.findNear(geometry, distance, limit); + } + + @PostMapping("/landmarks/within") + public List within(@RequestBody WithinLandmarkRequest request) { + return landmarkService.findWithin(request.geometry(), request.mask()); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkService.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkService.java new file mode 100644 index 00000000..9bd12b04 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/LandmarkService.java @@ -0,0 +1,111 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +import java.util.Arrays; +import java.util.Locale; +import java.util.List; + +import com.oracle.spring.spatial.OracleSpatialJdbcOperations; +import com.oracle.spring.spatial.SpatialExpression; +import com.oracle.spring.spatial.SpatialGeometry; +import com.oracle.spring.spatial.SpatialPredicate; +import com.oracle.spring.spatial.SpatialRelationMask; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Service; + +@Service +public class LandmarkService { + private final JdbcClient jdbcClient; + private final OracleSpatialJdbcOperations spatial; + + public LandmarkService(JdbcClient jdbcClient, + OracleSpatialJdbcOperations spatial) { + this.jdbcClient = jdbcClient; + this.spatial = spatial; + } + + public Landmark create(Landmark landmark) { + SpatialGeometry geometry = spatial.geometry(landmark.geometry()); + SpatialExpression insertGeometry = spatial.fromGeoJson(geometry); + spatial.bind( + jdbcClient.sql("insert into landmarks (id, name, category, geometry) values (:id, :name, :category, " + + insertGeometry.expression() + ")"), + insertGeometry) + .param("id", landmark.id()) + .param("name", landmark.name()) + .param("category", landmark.category()) + .update(); + return getById(landmark.id()); + } + + public Landmark getById(Long id) { + SpatialExpression geometry = spatial.toGeoJson("geometry"); + return jdbcClient.sql("select id, name, category, " + + geometry.selection("geometry") + " from landmarks where id = :id") + .param("id", id) + .query(this::mapLandmark) + .single(); + } + + public List findNear(String geometry, Integer distance, Integer limit) { + int effectiveDistance = distance == null ? 2000 : distance; + int effectiveLimit = limit == null ? 3 : limit; + SpatialGeometry referenceGeometry = spatial.geometry(geometry); + SpatialExpression projectedGeometry = spatial.toGeoJson("geometry"); + SpatialExpression distanceExpression = spatial.distance("geometry", referenceGeometry, 0.005); + SpatialPredicate withinDistance = spatial.withinDistance("geometry", referenceGeometry, effectiveDistance); + return spatial.bind( + jdbcClient.sql("select id, name, category, " + + projectedGeometry.selection("geometry") + ", " + + distanceExpression.selection("distance") + + " from landmarks where " + + withinDistance.clause() + + " order by distance fetch first " + effectiveLimit + " rows only") + , distanceExpression, withinDistance) + .query(this::mapLandmark) + .list(); + } + + public List findWithin(String geometry, String mask) { + SpatialGeometry referenceGeometry = spatial.geometry(geometry); + SpatialExpression projectedGeometry = spatial.toGeoJson("geometry"); + SpatialPredicate filter = spatial.filter("geometry", referenceGeometry); + SpatialPredicate relate = spatial.relate("geometry", referenceGeometry, resolveMask(mask)); + return spatial.bind( + jdbcClient.sql("select id, name, category, " + + projectedGeometry.selection("geometry") + " from landmarks where " + + filter.clause() + + " and " + relate.clause()), + filter, relate) + .query(this::mapLandmark) + .list(); + } + + private Landmark mapLandmark(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + return new Landmark( + rs.getLong("id"), + rs.getString("name"), + rs.getString("category"), + rs.getString("geometry") + ); + } + + private SpatialRelationMask resolveMask(String mask) { + if (mask == null || mask.isBlank()) { + return SpatialRelationMask.ANYINTERACT; + } + try { + return SpatialRelationMask.valueOf(mask.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new InvalidSpatialMaskException(mask, supportedMasks()); + } + } + + private String supportedMasks() { + return Arrays.stream(SpatialRelationMask.values()) + .map(Enum::name) + .toList() + .toString(); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleApplication.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleApplication.java new file mode 100644 index 00000000..7db7254e --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleApplication.java @@ -0,0 +1,13 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpatialSampleApplication { + public static void main(String[] args) { + SpringApplication.run(SpatialSampleApplication.class, args); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleExceptionHandler.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleExceptionHandler.java new file mode 100644 index 00000000..a6d38a6c --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/SpatialSampleExceptionHandler.java @@ -0,0 +1,16 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class SpatialSampleExceptionHandler { + @ExceptionHandler(InvalidSpatialMaskException.class) + ProblemDetail handleInvalidSpatialMask(InvalidSpatialMaskException exception) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage()); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/WithinLandmarkRequest.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/WithinLandmarkRequest.java new file mode 100644 index 00000000..d35990ad --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/java/com/oracle/database/spring/spatial/WithinLandmarkRequest.java @@ -0,0 +1,6 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +public record WithinLandmarkRequest(String geometry, String mask) { +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/resources/application.yaml b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/resources/application.yaml new file mode 100644 index 00000000..6b7eaa0c --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + datasource: + driver-class-name: oracle.jdbc.OracleDriver + type: oracle.ucp.jdbc.PoolDataSourceImpl + oracleucp: + initial-pool-size: 1 + min-pool-size: 1 + max-pool-size: 30 + connection-pool-name: SpatialSampleApplication + connection-factory-class-name: oracle.jdbc.pool.OracleDataSource +server: + port: 9002 diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/SpatialSampleApplicationTest.java b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/SpatialSampleApplicationTest.java new file mode 100644 index 00000000..aa3b3d76 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/java/com/oracle/database/spring/spatial/SpatialSampleApplicationTest.java @@ -0,0 +1,125 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. +package com.oracle.database.spring.spatial; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; +import org.testcontainers.oracle.OracleContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Testcontainers +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class SpatialSampleApplicationTest { + @Container + @ServiceConnection + static OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-free:23.26.1-full-faststart") + .withStartupTimeout(Duration.ofMinutes(2)) + .withUsername("testuser") + .withPassword("testpwd"); + + @LocalServerPort + int port; + + @Test + @Sql("/init.sql") + void spatialSampleApp() { + RestClient restClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .build(); + + Landmark inserted = new Landmark( + 10L, + "Oracle Park", + "STADIUM", + "{\"type\":\"Point\",\"coordinates\":[-122.3893,37.7786]}" + ); + + Landmark created = restClient.post() + .uri("/landmarks") + .body(inserted) + .retrieve() + .body(Landmark.class); + assertThat(created).isNotNull(); + assertThat(created.name()).isEqualTo("Oracle Park"); + assertThat(created.geometry()).contains("\"Point\""); + + Landmark fetched = restClient.get() + .uri("/landmarks/10") + .retrieve() + .body(Landmark.class); + assertThat(fetched).isNotNull(); + assertThat(fetched.category()).isEqualTo("STADIUM"); + + String nearGeometry = "{\"type\":\"Point\",\"coordinates\":[-122.3933,37.7955]}"; + URI nearUri = UriComponentsBuilder.fromPath("/landmarks/near") + .queryParam("geometry", nearGeometry) + .queryParam("distance", 5000) + .queryParam("limit", 2) + .build() + .encode() + .toUri(); + Landmark[] nearResults = restClient.get() + .uri(nearUri) + .retrieve() + .body(Landmark[].class); + assertThat(nearResults).isNotNull(); + assertThat(List.of(nearResults)).isNotEmpty(); + assertThat(nearResults[0].name()).isEqualTo("Ferry Building"); + + WithinLandmarkRequest withinRequest = new WithinLandmarkRequest( + "{\"type\":\"Polygon\",\"coordinates\":[[[-122.53,37.70],[-122.35,37.70],[-122.35,37.83],[-122.53,37.83],[-122.53,37.70]]]}", + "ANYINTERACT" + ); + Landmark[] withinResults = restClient.post() + .uri("/landmarks/within") + .body(withinRequest) + .retrieve() + .body(Landmark[].class); + assertThat(withinResults).isNotNull(); + assertThat(List.of(withinResults)) + .extracting(Landmark::name) + .contains("Ferry Building", "Union Square", "Oracle Park"); + } + + @Test + @Sql("/init.sql") + void invalidWithinMaskReturnsBadRequest() { + RestClient restClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .build(); + + WithinLandmarkRequest invalidRequest = new WithinLandmarkRequest( + "{\"type\":\"Polygon\",\"coordinates\":[[[-122.53,37.70],[-122.35,37.70],[-122.35,37.83],[-122.53,37.83],[-122.53,37.70]]]}", + "INTERSECTS" + ); + + assertThatThrownBy(() -> restClient.post() + .uri("/landmarks/within") + .body(invalidRequest) + .retrieve() + .body(String.class)) + .isInstanceOf(RestClientResponseException.class) + .satisfies(exception -> { + RestClientResponseException responseException = (RestClientResponseException) exception; + assertThat(responseException.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(responseException.getResponseBodyAsString()) + .contains("Unsupported spatial relation mask 'INTERSECTS'") + .contains("ANYINTERACT"); + }); + } +} diff --git a/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/resources/init.sql b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/resources/init.sql new file mode 100644 index 00000000..59763533 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial/src/test/resources/init.sql @@ -0,0 +1,48 @@ +create table if not exists landmarks ( + id number primary key, + name varchar2(200) not null, + category varchar2(100) not null, + geometry mdsys.sdo_geometry not null +); + +delete from user_sdo_geom_metadata +where table_name = 'LANDMARKS' + and column_name = 'GEOMETRY'; + +insert into user_sdo_geom_metadata (table_name, column_name, diminfo, srid) +values ( + 'LANDMARKS', + 'GEOMETRY', + mdsys.sdo_dim_array( + mdsys.sdo_dim_element('LONG', -180, 180, 0.005), + mdsys.sdo_dim_element('LAT', -90, 90, 0.005) + ), + 4326 +); + +create index if not exists landmarks_spatial_idx +on landmarks (geometry) +indextype is mdsys.spatial_index_v2; + +delete from landmarks; + +insert into landmarks (id, name, category, geometry) +values (1, 'Ferry Building', 'MARKET', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3933,37.7955]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (2, 'Union Square', 'PLAZA', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.4074,37.7879]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (3, 'Golden Gate Park', 'PARK', sdo_util.from_geojson('{"type":"Polygon","coordinates":[[[-122.511,37.771],[-122.454,37.771],[-122.454,37.768],[-122.511,37.768],[-122.511,37.771]]]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (4, 'Oracle Park', 'STADIUM', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3893,37.7786]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (5, 'Salesforce Tower', 'SKYSCRAPER', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3969,37.7897]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (6, 'Transamerica Pyramid', 'SKYSCRAPER', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.4039,37.7952]}', null, 4326)); + +insert into landmarks (id, name, category, geometry) +values (7, 'Coit Tower', 'TOWER', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.4058,37.8024]}', null, 4326)); diff --git a/database/starters/oracle-spring-boot-starter-samples/pom.xml b/database/starters/oracle-spring-boot-starter-samples/pom.xml index d1eea23c..16994d0b 100644 --- a/database/starters/oracle-spring-boot-starter-samples/pom.xml +++ b/database/starters/oracle-spring-boot-starter-samples/pom.xml @@ -53,6 +53,7 @@ oracle-spring-boot-sample-json-duality oracle-spring-boot-sample-json-events oracle-spring-boot-sample-okafka + oracle-spring-boot-sample-spatial oracle-spring-boot-sample-otel oracle-spring-boot-sample-wallet oracle-spring-boot-sample-txeventqjms diff --git a/database/starters/oracle-spring-boot-starter-spatial/README.md b/database/starters/oracle-spring-boot-starter-spatial/README.md new file mode 100644 index 00000000..c63ddac9 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-spatial/README.md @@ -0,0 +1,63 @@ +# Oracle Spring Boot Starter for Oracle Spatial + +The Oracle Spring Boot Starter for Oracle Spatial provides Spring Boot auto-configuration and Spring JDBC-oriented helpers for working with Oracle Spatial `SDO_GEOMETRY` data using GeoJSON-first APIs. + +This starter is focused on geographic and topographic spatial data. It does not provide support for Oracle Database AI `VECTOR` columns or vector similarity search. + +The starter contributes: + +- `OracleSpatialJdbcOperations` as the main spatial JDBC integration bean +- `OracleSpatialProperties` for default SRID and distance-unit settings + +Applications remain responsible for creating spatial tables, populating `USER_SDO_GEOM_METADATA`, and managing `MDSYS.SPATIAL_INDEX_V2` indexes through migrations or setup SQL. + +## Dependency Coordinates + +```xml + + com.oracle.database.spring + oracle-spring-boot-starter-spatial + +``` + +## Configuration Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `oracle.database.spatial.enabled` | `boolean` | `true` | Enables or disables the spatial auto-configuration | +| `oracle.database.spatial.default-srid` | `int` | `4326` | SRID embedded in generated `SDO_UTIL.FROM_GEOJSON` calls; must be positive | +| `oracle.database.spatial.default-distance-unit` | `String` | `M` | Distance unit token appended to generated `SDO_WITHIN_DISTANCE` and `SDO_GEOM.SDO_DISTANCE` clauses; Oracle-supported values include `M`, `KM`, and `UNIT=MILE` | + +## Example + +Inject the helper bean into a Spring JDBC service: + +```java +@Service +class LandmarkService { + private final JdbcClient jdbcClient; + private final OracleSpatialJdbcOperations spatial; + + LandmarkService(JdbcClient jdbcClient, OracleSpatialJdbcOperations spatial) { + this.jdbcClient = jdbcClient; + this.spatial = spatial; + } +} +``` + +Typical query flow: + +- create a `SpatialGeometry` from GeoJSON +- derive a `SpatialExpression` or `SpatialPredicate` +- build the full SQL statement in `JdbcClient` +- call `spatial.bind(...)` to apply the spatial bind values + +## Query Guidance + +- Use `SDO_FILTER` for a fast primary spatial filter. +- Use `SDO_RELATE` when you need exact relationship masks such as `ANYINTERACT` or `INSIDE`. +- Use `SDO_WITHIN_DISTANCE` for radius-based filtering. +- Use `SDO_NN` for nearest-neighbor searches. +- Use `OracleSpatialJdbcOperations.geoJsonRowMapper(...)` or a custom `RowMapper` when projecting `SDO_UTIL.TO_GEOJSON(...)`. +- Do not combine `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same `WHERE` clause. +- Use `SDO_WITHIN_DISTANCE` ordered by `SDO_GEOM.SDO_DISTANCE` when you need both a distance bound and a result count. diff --git a/database/starters/oracle-spring-boot-starter-spatial/pom.xml b/database/starters/oracle-spring-boot-starter-spatial/pom.xml new file mode 100644 index 00000000..724a8ad8 --- /dev/null +++ b/database/starters/oracle-spring-boot-starter-spatial/pom.xml @@ -0,0 +1,57 @@ + + + + + 4.0.0 + + oracle-spring-boot-starters + com.oracle.database.spring + 26.1.0 + ../pom.xml + + + oracle-spring-boot-starter-spatial + 26.1.0 + + Oracle Spring Boot Starter - Spatial + Spring Boot Starter for Oracle Database Spatial + https://github.com/oracle/spring-cloud-oracle/tree/main/database/starters/oracle-spring-boot-starter-spatial + + + Oracle America, Inc. + https://www.oracle.com + + + + + Oracle + obaas_ww at oracle.com + Oracle America, Inc. + https://www.oracle.com + + + + + + The Universal Permissive License (UPL), Version 1.0 + https://oss.oracle.com/licenses/upl/ + repo + + + + + https://github.com/oracle/spring-cloud-oracle + scm:git:https://github.com/oracle/spring-cloud-oracle.git + scm:git:git@github.com:oracle/spring-cloud-oracle.git + + + + + com.oracle.database.spring + oracle-spring-boot-spatial-data-tools + ${project.version} + + + diff --git a/database/starters/pom.xml b/database/starters/pom.xml index bf5de259..d8fe23ae 100644 --- a/database/starters/pom.xml +++ b/database/starters/pom.xml @@ -55,10 +55,12 @@ oracle-spring-boot-json-relational-duality-views oracle-spring-boot-json-data-tools + oracle-spring-boot-spatial-data-tools oracle-spring-boot-starter-ucp oracle-spring-boot-starter-wallet oracle-spring-boot-starter-aqjms oracle-spring-boot-starter-json-collections + oracle-spring-boot-starter-spatial oracle-spring-boot-starter-samples oracle-spring-boot-starter-okafka spring-boot-starter-oracle-otel diff --git a/site/docs/database/spatial.md b/site/docs/database/spatial.md new file mode 100644 index 00000000..5490c4bd --- /dev/null +++ b/site/docs/database/spatial.md @@ -0,0 +1,244 @@ +--- +title: Oracle Spatial +sidebar_position: 6 +--- + +The Oracle Spatial starter adds Spring Boot auto-configuration for GeoJSON-first Oracle Spatial development with [`SDO_GEOMETRY`](https://docs.oracle.com/en/database/oracle/oracle-database/23/spatl/sdo_geometry-object-type.html). + +This starter is for geographic and topographic spatial data. + +## Dependency Coordinates + +```xml + + com.oracle.database.spring + oracle-spring-boot-starter-spatial + +``` + +## Provided Beans + +When Oracle JDBC is on the classpath and a `DataSource` is present, the starter auto-configures: + +- `OracleSpatialJdbcOperations` +- `OracleSpatialProperties` + +If your application provides its own bean of the same type, the starter backs off and uses your custom bean instead. + +## What You Inject vs What You Build + +The starter injects one main working bean: + +- `OracleSpatialJdbcOperations` + - the Spring JDBC entry point for spatial work + - creates GeoJSON-backed bind values + - creates bindable SQL expressions and predicates + - provides a `RowMapper` for projected GeoJSON columns + - applies spatial bind parameters to `JdbcClient.StatementSpec` + +Per query, `OracleSpatialJdbcOperations` creates lightweight value objects: + +- `SpatialGeometry` + - a GeoJSON payload plus SRID +- `SpatialExpression` + - a SQL expression such as `SDO_UTIL.TO_GEOJSON(...)` or `SDO_GEOM.SDO_DISTANCE(...)` +- `SpatialPredicate` + - a SQL predicate such as `SDO_FILTER(...) = 'TRUE'` +- `SpatialRelationMask` + - enum values for `SDO_RELATE` masks such as `ANYINTERACT`, `INSIDE`, and `CONTAINS` + +These are not Spring beans. They are query parts that keep the spatial SQL fragment and its JDBC bind values together. + +## Configuration Properties + +```yaml +oracle: + database: + spatial: + enabled: true + default-srid: 4326 + default-distance-unit: M +``` + +`default-distance-unit` is intentionally flexible and can be set to Oracle-style unit tokens such as `M`, `KM`, or `UNIT=MILE`. + +These properties affect generated SQL directly: + +- `default-srid` is used when GeoJSON is converted to `SDO_GEOMETRY` +- `default-distance-unit` is used when distance clauses are generated for `SDO_WITHIN_DISTANCE` and `SDO_GEOM.SDO_DISTANCE` + +If you are new to SRIDs, Oracle uses the SRID value to identify the geometry's spatial reference system or coordinate system. [Oracle's coordinate system documentation](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/coordinate-systems-spatial-reference-systems.html) and the [`SDO_SRID` section of the `SDO_GEOMETRY` reference](https://docs.oracle.com/en/database/oracle/oracle-database/23/spatl/sdo_geometry-object-type.html) are the best places to start. + +## Using the Starter + +Inject `OracleSpatialJdbcOperations` into a Spring JDBC service and let it supply the spatial expressions, predicates, and row mapping while your code still owns the full SQL statement: + +```java +@Service +class LandmarkService { + private final JdbcClient jdbcClient; + private final OracleSpatialJdbcOperations spatial; + + LandmarkService(JdbcClient jdbcClient, OracleSpatialJdbcOperations spatial) { + this.jdbcClient = jdbcClient; + this.spatial = spatial; + } + + Landmark create(Landmark landmark) { + SpatialGeometry geometry = spatial.geometry(landmark.geometry()); + SpatialExpression insertGeometry = spatial.fromGeoJson(geometry); + + spatial.bind( + jdbcClient.sql("insert into landmarks (id, name, category, geometry) values (:id, :name, :category, " + + insertGeometry.expression() + ")"), + insertGeometry) + .param("id", landmark.id()) + .param("name", landmark.name()) + .param("category", landmark.category()) + .update(); + + SpatialExpression projectedGeometry = spatial.toGeoJson("geometry"); + return jdbcClient.sql("select id, name, category, " + + projectedGeometry.selection("geometry") + + " from landmarks where id = :id") + .param("id", landmark.id()) + .query((rs, rowNum) -> new Landmark( + rs.getLong("id"), + rs.getString("name"), + rs.getString("category"), + rs.getString("geometry"))) + .single(); + } +} +``` + +In this pattern: + +- the application boundary stays GeoJSON-first +- the starter keeps Oracle Spatial SQL fragments attached to their JDBC bind values +- `JdbcClient` still owns the statement lifecycle +- schema creation, metadata registration, and spatial index creation remain outside the starter +- generated bind names such as `spatialGeometry1` are internal implementation details and may increment over time, so callers should not depend on specific parameter names in logs + +## `OracleSpatialJdbcOperations` + +`OracleSpatialJdbcOperations` is the main API exposed by the starter. + +Geometry creation: + +- `geometry(String geoJson)` +- `geometry(String geoJson, int srid)` + +Expression creation: + +- `fromGeoJson(SpatialGeometry geometry)` + - returns `SDO_UTIL.FROM_GEOJSON(...)` +- `toGeoJson(String geometryColumn)` + - returns `SDO_UTIL.TO_GEOJSON(...)` +- `nearestNeighborDistance()` + - returns `SDO_NN_DISTANCE(1)` +- `distance(String geometryColumn, SpatialGeometry geometry, Number tolerance)` +- `distance(String geometryColumn, SpatialGeometry geometry, Number tolerance, String unit)` + - return `SDO_GEOM.SDO_DISTANCE(...)` + +Predicate creation: + +- `filter(String geometryColumn, SpatialGeometry geometry)` + - wraps [`SDO_FILTER`](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_filter.html) +- `relate(String geometryColumn, SpatialGeometry geometry, SpatialRelationMask mask)` + - wraps [`SDO_RELATE`](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_relate.html) +- `withinDistance(String geometryColumn, SpatialGeometry geometry, Number distance)` +- `withinDistance(String geometryColumn, SpatialGeometry geometry, Number distance, String unit)` + - wrap [`SDO_WITHIN_DISTANCE`](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_within_distance.html) +- `nearestNeighbor(String geometryColumn, SpatialGeometry geometry, int numResults)` + - wraps [`SDO_NN`](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_nn.html) + +Spring JDBC integration: + +- `bind(JdbcClient.StatementSpec statement, SpatialJdbcBindable... parts)` + - applies bind values from spatial expressions and predicates to a `JdbcClient` statement +- `geoJsonRowMapper(String columnLabel)` + - returns a `RowMapper` for GeoJSON projections + +## Query Patterns + +Insert GeoJSON as `SDO_GEOMETRY`: + +```java +SpatialGeometry geometry = spatial.geometry(geoJson); +SpatialExpression insertGeometry = spatial.fromGeoJson(geometry); + +spatial.bind( + jdbcClient.sql("insert into landmarks (geometry) values (" + insertGeometry.expression() + ")"), + insertGeometry) + .update(); +``` + +Project `SDO_GEOMETRY` back to GeoJSON: + +```java +SpatialExpression projectedGeometry = spatial.toGeoJson("geometry"); + +String geoJson = jdbcClient.sql("select " + projectedGeometry.selection("geometry") + " from landmarks where id = :id") + .param("id", id) + .query(spatial.geoJsonRowMapper("geometry")) + .single(); +``` + +Apply a filter plus exact relationship check: + +```java +SpatialGeometry searchGeometry = spatial.geometry(polygonGeoJson); +SpatialPredicate filter = spatial.filter("geometry", searchGeometry); +SpatialPredicate relate = spatial.relate("geometry", searchGeometry, SpatialRelationMask.ANYINTERACT); + +spatial.bind( + jdbcClient.sql("select id from landmarks where " + + filter.clause() + " and " + relate.clause()), + filter, relate) + .query(Long.class) + .list(); +``` + +Find nearby rows and order by distance: + +```java +SpatialGeometry referenceGeometry = spatial.geometry(pointGeoJson); +SpatialPredicate within = spatial.withinDistance("geometry", referenceGeometry, 2000); +SpatialExpression distance = spatial.distance("geometry", referenceGeometry, 0.005); + +spatial.bind( + jdbcClient.sql("select id, " + distance.selection("distance") + + " from landmarks where " + within.clause() + + " order by distance fetch first 3 rows only"), + within, distance) + .query((rs, rowNum) -> rs.getLong("id")) + .list(); +``` + +## Usage Notes + +- Manage spatial table DDL, `USER_SDO_GEOM_METADATA`, and spatial index creation in your migrations or setup SQL rather than expecting starter beans to create them. +- Use `SDO_FILTER` as a primary filter and `SDO_RELATE` for exact mask-based checks. +- Use `SDO_WITHIN_DISTANCE` for radius filtering and `SDO_NN` for nearest-neighbor searches. +- Do not combine `SDO_NN` and `SDO_WITHIN_DISTANCE` in the same `WHERE` clause. +- Use `SDO_WITHIN_DISTANCE` ordered by `SDO_GEOM.SDO_DISTANCE` when you need both a distance bound and a result count. +- `nearestNeighbor(...)` and `nearestNeighborDistance()` currently assume Oracle operator id `1`. If you need more advanced SQL with multiple `SDO_NN` operators in a single statement, build that query manually. + +## Further Reading + +- [Oracle Spatial Concepts](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/spatial-concepts.html) +- [SDO_GEOMETRY Object Type](https://docs.oracle.com/en/database/oracle/oracle-database/23/spatl/sdo_geometry-object-type.html) +- [Coordinate Systems (Spatial Reference Systems)](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/coordinate-systems-spatial-reference-systems.html) +- [SDO_SRID in the spatial data types and metadata reference](https://docs.oracle.com/en/database/oracle/oracle-database/21/spatl/spatial-datatypes-metadata.html) +- [SDO_UTIL.FROM_GEOJSON](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_util-from_geojson.html) +- [SDO_UTIL.TO_GEOJSON](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_util-to_geojson.html) +- [SDO_FILTER](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_filter.html) +- [SDO_RELATE](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_relate.html) +- [SDO_WITHIN_DISTANCE](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_within_distance.html) +- [SDO_NN](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_nn.html) +- [SDO_NN_DISTANCE](https://docs.oracle.com/en/database/oracle/oracle-database/26/spatl/sdo_nn_distance.html) + +## Sample + +See the spatial sample application under `database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial` for a REST-based example that stores and queries `SDO_GEOMETRY` values using GeoJSON. Its `GET /landmarks/near` endpoint accepts compact GeoJSON in the `geometry` query parameter.