@an5/adapters 0.2.11 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +137 -5
  2. package/dist/an5Adapter.d.ts +42 -1
  3. package/dist/an5Adapter.js +146 -38
  4. package/dist/base/types.d.ts +14 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +8 -1
  7. package/dist/sqlite/browserEngine.d.ts +13 -0
  8. package/dist/sqlite/browserEngine.js +13 -0
  9. package/dist/sqlite/engine.d.ts +5 -0
  10. package/dist/sqlite/engine.js +10 -0
  11. package/dist/sqlite/index.d.ts +1 -0
  12. package/dist/sqlite/index.js +1 -0
  13. package/dist/sqlite/vector.d.ts +155 -0
  14. package/dist/sqlite/vector.js +432 -0
  15. package/dotnet/An5Adapter.cs +99 -28
  16. package/dotnet/Base/Core.cs +14 -0
  17. package/dotnet/Mssql/MssqlEngine.cs +44 -1
  18. package/dotnet/Postgres/PostgresEngine.cs +2 -0
  19. package/dotnet/Sqlite/SqliteEngine.cs +82 -2
  20. package/dotnet/Sqlite/SqliteVectors.cs +401 -0
  21. package/golang/an5_adapter.go +84 -32
  22. package/golang/base/vectors.go +263 -0
  23. package/golang/sqlite/vector.go +248 -0
  24. package/golang/sqlite_vector_test.go +288 -0
  25. package/golang/table_client.go +67 -3
  26. package/java/pom.xml +177 -0
  27. package/java/src/main/java/an5/adapters/An5Adapter.java +374 -0
  28. package/java/src/main/java/an5/adapters/An5Aggregate.java +116 -0
  29. package/java/src/main/java/an5/adapters/An5GroupBy.java +97 -0
  30. package/java/src/main/java/an5/adapters/An5Query.java +100 -0
  31. package/java/src/main/java/an5/adapters/An5TableClient.java +1030 -0
  32. package/java/src/main/java/an5/adapters/An5ViewClient.java +107 -0
  33. package/java/src/main/java/an5/adapters/base/Dialect.java +103 -0
  34. package/java/src/main/java/an5/adapters/base/FieldMeta.java +42 -0
  35. package/java/src/main/java/an5/adapters/base/JdbcUrls.java +201 -0
  36. package/java/src/main/java/an5/adapters/base/Metadata.java +195 -0
  37. package/java/src/main/java/an5/adapters/base/RelationDef.java +26 -0
  38. package/java/src/main/java/an5/adapters/base/SqlBuilder.java +422 -0
  39. package/java/src/main/java/an5/adapters/base/SqliteVectors.java +375 -0
  40. package/java/src/main/java/an5/adapters/base/Vectors.java +123 -0
  41. package/java/src/test/java/An5AdaptersSmoke.java +733 -0
  42. package/java/src/test/java/Json.java +174 -0
  43. package/java/src/test/java/QuerySemantics.java +111 -0
  44. package/kotlin/build.gradle.kts +145 -0
  45. package/kotlin/settings.gradle.kts +10 -0
  46. package/kotlin/src/main/kotlin/an5/adapters/Aggregate.kt +89 -0
  47. package/kotlin/src/main/kotlin/an5/adapters/An5.kt +91 -0
  48. package/kotlin/src/main/kotlin/an5/adapters/Query.kt +130 -0
  49. package/kotlin/src/main/kotlin/an5/adapters/Rows.kt +166 -0
  50. package/kotlin/src/main/kotlin/an5/adapters/TableClient.kt +169 -0
  51. package/kotlin/src/main/kotlin/an5/adapters/Types.kt +77 -0
  52. package/kotlin/src/test/kotlin/an5/adapters/An5Smoke.kt +329 -0
  53. package/native/sqlite/CMakeLists.txt +12 -0
  54. package/native/sqlite/README.md +117 -0
  55. package/native/sqlite/an5_vector.c +209 -0
  56. package/package.json +33 -7
  57. package/python/an5_adapter.py +37 -6
  58. package/python/base/__init__.py +15 -0
  59. package/python/base/vectors.py +431 -0
  60. package/python/sqlite/__init__.py +2 -2
  61. package/python/sqlite/provider.py +21 -1
  62. package/python/table_client.py +129 -59
  63. package/rust/src/adapter.rs +342 -24
  64. package/rust/src/base/mod.rs +7 -0
  65. package/rust/src/base/vectors.rs +293 -0
  66. package/rust/src/lib.rs +3 -3
  67. package/scripts/sqlite-native-build.js +32 -0
  68. package/swift/Package.swift +22 -0
  69. package/swift/Sources/An5Adapters/An5Adapter.swift +160 -0
  70. package/swift/Sources/An5Adapters/Metadata.swift +183 -0
  71. package/swift/Sources/An5Adapters/Query.swift +105 -0
  72. package/swift/Sources/An5Adapters/Row+Accessors.swift +129 -0
  73. package/swift/Sources/An5Adapters/SQLBuilder.swift +359 -0
  74. package/swift/Sources/An5Adapters/SQLiteDriver.swift +301 -0
  75. package/swift/Sources/An5Adapters/SqliteVectors.swift +243 -0
  76. package/swift/Sources/An5Adapters/TableClient.swift +671 -0
  77. package/swift/Sources/An5Adapters/Types.swift +127 -0
  78. package/swift/Sources/An5Adapters/Vectors.swift +86 -0
  79. package/swift/Sources/An5Adapters/ViewClient.swift +50 -0
  80. package/swift/Sources/CSQLite/module.modulemap +8 -0
  81. package/swift/Sources/CSQLite/shim.h +9 -0
  82. package/swift/Tests/An5AdaptersTests/QuerySemanticsTests.swift +51 -0
  83. package/swift/Tests/An5AdaptersTests/RuntimeSmokeTests.swift +488 -0
  84. package/typescript/src/an5Adapter.ts +155 -31
  85. package/typescript/src/base/types.ts +15 -0
  86. package/typescript/src/index.ts +13 -0
  87. package/typescript/src/sqlite/browserEngine.ts +24 -0
  88. package/typescript/src/sqlite/engine.ts +13 -0
  89. package/typescript/src/sqlite/index.ts +1 -1
  90. package/typescript/src/sqlite/vector.ts +537 -0
  91. package/golang/sqlite/sqlite.go +0 -14
@@ -0,0 +1,107 @@
1
+ package an5.adapters;
2
+
3
+ import java.sql.SQLException;
4
+ import java.util.List;
5
+ import java.util.Map;
6
+
7
+ /**
8
+ * A read-only client for a database view.
9
+ *
10
+ * <p>Reads go straight to the underlying table client, which already knows how to select
11
+ * from an arbitrary name. Every mutation is refused rather than attempted: a view has no
12
+ * rows to write, and the databases disagree about whether the statement is even legal, so
13
+ * the error is raised here where it can name the reason.
14
+ */
15
+ public class An5ViewClient {
16
+
17
+ private final An5TableClient client;
18
+ private final String viewName;
19
+
20
+ public An5ViewClient(An5Adapter adapter, String viewName) {
21
+ this.client = adapter.table(viewName);
22
+ this.viewName = viewName;
23
+ }
24
+
25
+ public String viewName() {
26
+ return viewName;
27
+ }
28
+
29
+ public List<Map<String, Object>> findMany() throws SQLException {
30
+ return client.findMany();
31
+ }
32
+
33
+ public List<Map<String, Object>> findMany(An5Query query) throws SQLException {
34
+ return client.findMany(query);
35
+ }
36
+
37
+ public Map<String, Object> findFirst(An5Query query) throws SQLException {
38
+ return client.findFirst(query);
39
+ }
40
+
41
+ public Map<String, Object> findFirst(Map<String, Object> where) throws SQLException {
42
+ return client.findFirst(where);
43
+ }
44
+
45
+ public Map<String, Object> findUnique(An5Query query) throws SQLException {
46
+ return client.findUnique(query);
47
+ }
48
+
49
+ public Map<String, Object> findUnique(Map<String, Object> where) throws SQLException {
50
+ return client.findUnique(where);
51
+ }
52
+
53
+ public long count() throws SQLException {
54
+ return client.count();
55
+ }
56
+
57
+ public long count(Map<String, Object> where) throws SQLException {
58
+ return client.count(where);
59
+ }
60
+
61
+ public Map<String, Object> aggregate(An5Aggregate aggregate) throws SQLException {
62
+ return client.aggregate(aggregate);
63
+ }
64
+
65
+ public List<Map<String, Object>> groupBy(An5GroupBy group) throws SQLException {
66
+ return client.groupBy(group);
67
+ }
68
+
69
+ public List<Map<String, Object>> vectorSearch(
70
+ double[] vector, Integer take, Map<String, Object> where, String vectorField, String metric)
71
+ throws SQLException {
72
+ return client.vectorSearch(vector, take, where, vectorField, metric);
73
+ }
74
+
75
+ public Map<String, Object> create(Map<String, Object> data) throws SQLException {
76
+ throw readOnly("create");
77
+ }
78
+
79
+ public Map<String, Object> update(Map<String, Object> where, Map<String, Object> data)
80
+ throws SQLException {
81
+ throw readOnly("update");
82
+ }
83
+
84
+ public Map<String, Object> updateMany(Map<String, Object> where, Map<String, Object> data)
85
+ throws SQLException {
86
+ throw readOnly("updateMany");
87
+ }
88
+
89
+ public Map<String, Object> delete(Map<String, Object> where) throws SQLException {
90
+ throw readOnly("delete");
91
+ }
92
+
93
+ public Map<String, Object> deleteMany(Map<String, Object> where) throws SQLException {
94
+ throw readOnly("deleteMany");
95
+ }
96
+
97
+ public Map<String, Object> upsert(
98
+ Map<String, Object> where, Map<String, Object> createData, Map<String, Object> updateData)
99
+ throws SQLException {
100
+ throw readOnly("upsert");
101
+ }
102
+
103
+ private UnsupportedOperationException readOnly(String operation) {
104
+ return new UnsupportedOperationException(
105
+ "View '" + viewName + "' is read-only; " + operation + " is not allowed on it.");
106
+ }
107
+ }
@@ -0,0 +1,103 @@
1
+ package an5.adapters.base;
2
+
3
+ import java.util.Locale;
4
+
5
+ /**
6
+ * The database dialects AN5 speaks, together with everything that differs between them.
7
+ *
8
+ * <p>The differences are not cosmetic. Quoting, pagination and the {@code WITH (NOLOCK)}
9
+ * hint each have to be produced per dialect, and getting one wrong yields SQL the target
10
+ * engine cannot parse — so all of it is decided here rather than at each call site.
11
+ */
12
+ public enum Dialect {
13
+ MSSQL("mssql"),
14
+ POSTGRES("postgres"),
15
+ SQLITE("sqlite");
16
+
17
+ private final String id;
18
+
19
+ Dialect(String id) {
20
+ this.id = id;
21
+ }
22
+
23
+ /** The lower-case identifier used in connection strings and generated metadata. */
24
+ public String id() {
25
+ return id;
26
+ }
27
+
28
+ /**
29
+ * The dialect a connection string points at.
30
+ *
31
+ * <p>Lower-cased first: a URI scheme is case-insensitive, so {@code MySQL://} and
32
+ * {@code mysql://} are the same connection.
33
+ */
34
+ public static Dialect detect(String connectionString) {
35
+ String cs = connectionString == null ? "" : connectionString.trim().toLowerCase(Locale.ROOT);
36
+ if (cs.startsWith("postgres://") || cs.startsWith("postgresql://")) {
37
+ return POSTGRES;
38
+ }
39
+ if (cs.startsWith("sqlite://")
40
+ || cs.startsWith("sqlite:")
41
+ || cs.startsWith("file:")
42
+ || cs.endsWith(".db")
43
+ || cs.endsWith(".sqlite")
44
+ || cs.endsWith(".sqlite3")
45
+ || cs.equals(":memory:")) {
46
+ return SQLITE;
47
+ }
48
+ return MSSQL;
49
+ }
50
+
51
+ /**
52
+ * The JDBC bind placeholder.
53
+ *
54
+ * <p>Always {@code ?}: every JDBC driver binds positionally. PostgreSQL's {@code %s} is
55
+ * a psycopg2-only spelling, so the other adapters' dialect split does not carry over.
56
+ */
57
+ public String placeholder() {
58
+ return "?";
59
+ }
60
+
61
+ /** Quotes a bare column or identifier name for this dialect. */
62
+ public String quoteIdentifier(String name) {
63
+ String raw = stripWrapping(name, "[", "]");
64
+ if (this == MSSQL) {
65
+ return "[" + raw.replace("]", "]]") + "]";
66
+ }
67
+ String unwrapped = stripWrapping(raw, "\"", "\"");
68
+ return "\"" + unwrapped.replace("\"", "\"\"") + "\"";
69
+ }
70
+
71
+ /** {@code WITH (NOLOCK)} is an MSSQL table hint; no other dialect has an equivalent. */
72
+ public boolean supportsNolock() {
73
+ return this == MSSQL;
74
+ }
75
+
76
+ /**
77
+ * The trailing limit clause.
78
+ *
79
+ * <p>SQLite has {@code LIMIT ... OFFSET} like PostgreSQL. Emitting the MSSQL
80
+ * {@code OFFSET ... FETCH NEXT} form against SQLite produces SQL it cannot parse, so
81
+ * the two are kept apart deliberately.
82
+ */
83
+ public String pagination(Integer take, int skip, String orderSql) {
84
+ if (take == null) {
85
+ return "";
86
+ }
87
+ if (this == MSSQL) {
88
+ String prefix = orderSql == null || orderSql.isEmpty() ? " ORDER BY (SELECT NULL)" : "";
89
+ return prefix + " OFFSET " + Math.max(0, skip) + " ROWS FETCH NEXT " + take + " ROWS ONLY";
90
+ }
91
+ return " LIMIT " + take + " OFFSET " + Math.max(0, skip);
92
+ }
93
+
94
+ private static String stripWrapping(String name, String left, String right) {
95
+ String value = name == null ? "" : String.valueOf(name);
96
+ if (value.length() >= left.length() + right.length()
97
+ && value.startsWith(left)
98
+ && value.endsWith(right)) {
99
+ return value.substring(left.length(), value.length() - right.length());
100
+ }
101
+ return value;
102
+ }
103
+ }
@@ -0,0 +1,42 @@
1
+ package an5.adapters.base;
2
+
3
+ /** One column of a model, as emitted by the generator into the metadata module. */
4
+ public final class FieldMeta {
5
+ public final String name;
6
+ public final String type;
7
+ public final String sqlType;
8
+ public final boolean optional;
9
+ public final boolean hasDefault;
10
+ public final boolean id;
11
+ public final String description;
12
+
13
+ public FieldMeta(
14
+ String name,
15
+ String type,
16
+ String sqlType,
17
+ boolean optional,
18
+ boolean hasDefault,
19
+ boolean id,
20
+ String description) {
21
+ this.name = name;
22
+ this.type = type;
23
+ this.sqlType = sqlType;
24
+ this.optional = optional;
25
+ this.hasDefault = hasDefault;
26
+ this.id = id;
27
+ this.description = description;
28
+ }
29
+
30
+ /** The primary key column of a model, or {@code null} when the schema marks none. */
31
+ public static FieldMeta idField(java.util.List<FieldMeta> fields) {
32
+ if (fields == null) {
33
+ return null;
34
+ }
35
+ for (FieldMeta field : fields) {
36
+ if (field.id) {
37
+ return field;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ }
@@ -0,0 +1,201 @@
1
+ package an5.adapters.base;
2
+
3
+ import java.util.Locale;
4
+ import java.util.Properties;
5
+
6
+ /**
7
+ * Connection strings to JDBC URLs.
8
+ *
9
+ * <p>A JDBC URL is mandatory and positional, while the connection strings the rest of AN5
10
+ * takes are ADO-style or URI-style. Every environment spellings are accepted here so a
11
+ * project can keep one {@code DATABASE_URL} across all its clients.
12
+ */
13
+ public final class JdbcUrls {
14
+
15
+ private JdbcUrls() {}
16
+
17
+ /** The JDBC URL for a connection string, for the dialect it points at. */
18
+ public static String url(String connectionString) {
19
+ Dialect dialect = Dialect.detect(connectionString);
20
+ String url = urlFor(dialect, connectionString);
21
+ if (url != null) {
22
+ return url;
23
+ }
24
+ throw new IllegalArgumentException(
25
+ "Unsupported connection string for " + dialect.id() + ": " + connectionString);
26
+ }
27
+
28
+ private static String urlFor(Dialect dialect, String connectionString) {
29
+ if (connectionString == null) {
30
+ return null;
31
+ }
32
+ String value = connectionString.trim();
33
+ if (dialect == Dialect.SQLITE) {
34
+ return sqliteUrl(value);
35
+ }
36
+ if (dialect == Dialect.POSTGRES) {
37
+ return postgresUrl(value);
38
+ }
39
+ return mssqlUrl(value);
40
+ }
41
+
42
+ /**
43
+ * SQLite needs no server, so the path is the whole URL.
44
+ *
45
+ * <p>The {@code sqlite:} and {@code sqlite://} wrappers are stripped one at a time and
46
+ * only then is {@code file:} looked for: {@code sqlite:file::memory:?cache=shared} is
47
+ * only a URI after {@code sqlite:} is gone, so one combined check would miss it.
48
+ */
49
+ private static String sqliteUrl(String value) {
50
+ String[] prefixes = {"sqlite:///", "sqlite://", "sqlite:"};
51
+ String lower = value.toLowerCase(Locale.ROOT);
52
+ for (String prefix : prefixes) {
53
+ if (lower.startsWith(prefix)) {
54
+ value = value.substring(prefix.length());
55
+ break;
56
+ }
57
+ }
58
+ // `sqlite:///C:/data/app.db` leaves a leading slash that belongs to the URL, not the
59
+ // path, on Windows.
60
+ if (value.length() > 2 && value.charAt(0) == '/' && value.charAt(2) == ':') {
61
+ value = value.substring(1);
62
+ }
63
+ if (value.isEmpty()) {
64
+ value = ":memory:";
65
+ }
66
+ return "jdbc:sqlite:" + value;
67
+ }
68
+
69
+ private static String postgresUrl(String value) {
70
+ String lower = value.toLowerCase(Locale.ROOT);
71
+ if (lower.startsWith("postgres://")) {
72
+ return "jdbc:postgresql://" + value.substring("postgres://".length());
73
+ }
74
+ if (lower.startsWith("postgresql://")) {
75
+ return "jdbc:postgresql://" + value.substring("postgresql://".length());
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * SQL Server is reached either through a {@code jdbc:sqlserver:} URL, which is passed
82
+ * through untouched, or through the ADO-style {@code Server=...;Database=...} string the
83
+ * other adapters take.
84
+ */
85
+ private static String mssqlUrl(String value) {
86
+ String lower = value.toLowerCase(Locale.ROOT);
87
+ if (lower.startsWith("jdbc:")) {
88
+ return value;
89
+ }
90
+ if (lower.startsWith("sqlserver://")) {
91
+ value = value.substring("sqlserver://".length());
92
+ lower = value.toLowerCase(Locale.ROOT);
93
+ }
94
+
95
+ String[] parts = value.split(";");
96
+ String host = "";
97
+ String port = "1433";
98
+ String database = "";
99
+ for (int i = 0; i < parts.length; i++) {
100
+ String part = parts[i].trim();
101
+ int at = part.indexOf('=');
102
+ if (at < 0) {
103
+ continue;
104
+ }
105
+ String key = part.substring(0, at).trim().toLowerCase(Locale.ROOT);
106
+ String entry = part.substring(at + 1).trim();
107
+ if (key.equals("server") || key.equals("data source") || key.equals("host") || key.equals("address")) {
108
+ // ADO spells the port as a comma (`Server=db,1433`); a JDBC URL as a colon. Both
109
+ // reach this function, so both separators have to be read.
110
+ int colon = entry.lastIndexOf(':');
111
+ int comma = entry.lastIndexOf(',');
112
+ int portSeparator = Math.max(colon, comma);
113
+ if (portSeparator > 0 && isNumeric(entry.substring(portSeparator + 1))) {
114
+ host = entry.substring(0, portSeparator);
115
+ port = entry.substring(portSeparator + 1);
116
+ } else {
117
+ host = entry;
118
+ }
119
+ } else if (key.equals("port")) {
120
+ port = entry;
121
+ } else if (key.equals("database") || key.equals("initial catalog")) {
122
+ database = entry;
123
+ }
124
+ }
125
+ if (host.isEmpty()) {
126
+ return null;
127
+ }
128
+ String url = "jdbc:sqlserver://" + host + ":" + port;
129
+ if (!database.isEmpty()) {
130
+ url += ";databaseName=" + database;
131
+ }
132
+ return url + ";encrypt=true;trustServerCertificate=true";
133
+ }
134
+
135
+ /**
136
+ * The username and password from a connection string, or {@code null} for either when
137
+ * the string carries none — Windows integrated authentication has both absent.
138
+ */
139
+ public static String[] credentials(String connectionString) {
140
+ if (connectionString == null) {
141
+ return new String[] {null, null};
142
+ }
143
+ String user = null;
144
+ String password = null;
145
+ for (String part : connectionString.split(";")) {
146
+ int separator = part.indexOf('=');
147
+ if (separator < 0) {
148
+ continue;
149
+ }
150
+ String key = part.substring(0, separator).trim().toLowerCase(Locale.ROOT);
151
+ String value = part.substring(separator + 1).trim();
152
+ if (key.equals("user") || key.equals("uid") || key.equals("user id")) {
153
+ user = value;
154
+ } else if (key.equals("password") || key.equals("pwd")) {
155
+ password = value;
156
+ }
157
+ }
158
+ return new String[] {user, password};
159
+ }
160
+
161
+ /**
162
+ * The SQLite file path, or {@code null} for an in-memory database.
163
+ *
164
+ * <p>An in-memory database does not survive every connection being closed, so
165
+ * {@code An5Adapter} keeps one connection open for exactly this case.
166
+ */
167
+ public static boolean isMemory(String connectionString) {
168
+ if (connectionString == null) {
169
+ return true;
170
+ }
171
+ String value = connectionString.trim().toLowerCase(Locale.ROOT);
172
+ if (value.equals(":memory:")) {
173
+ return true;
174
+ }
175
+ return value.contains("mode=memory") || value.startsWith("sqlite::memory:");
176
+ }
177
+
178
+ /** The connection properties for a username and password, either of which may be {@code null}. */
179
+ public static Properties properties(String username, String password) {
180
+ Properties properties = new Properties();
181
+ if (username != null) {
182
+ properties.setProperty("user", username);
183
+ }
184
+ if (password != null) {
185
+ properties.setProperty("password", password);
186
+ }
187
+ return properties;
188
+ }
189
+
190
+ private static boolean isNumeric(String value) {
191
+ if (value.isEmpty()) {
192
+ return false;
193
+ }
194
+ for (int i = 0; i < value.length(); i++) {
195
+ if (!Character.isDigit(value.charAt(i))) {
196
+ return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+ }
@@ -0,0 +1,195 @@
1
+ package an5.adapters.base;
2
+
3
+ import java.util.ArrayList;
4
+ import java.util.Collections;
5
+ import java.util.LinkedHashMap;
6
+ import java.util.List;
7
+ import java.util.Map;
8
+
9
+ /**
10
+ * The generated model's table names, columns and relations, registered at start-up.
11
+ *
12
+ * <p>The generated client calls {@link #setAdapterMetadata(Map)} once before it hands out a
13
+ * table client. Nothing here reads the schema, so the adapter stays independent of the
14
+ * generator.
15
+ */
16
+ public final class Metadata {
17
+
18
+ private static final Map<String, String> MODEL_TO_TABLE = new LinkedHashMap<String, String>();
19
+ private static final Map<String, List<FieldMeta>> MODEL_FIELDS =
20
+ new LinkedHashMap<String, List<FieldMeta>>();
21
+ private static final Map<String, Map<String, RelationDef>> RELATION_MAP =
22
+ new LinkedHashMap<String, Map<String, RelationDef>>();
23
+
24
+ private Metadata() {}
25
+
26
+ /**
27
+ * Replaces the registry with the metadata the generated client passed in.
28
+ *
29
+ * <p>Called once per client, so it clears first: a second client for another schema in
30
+ * the same JVM must not see the first one's tables.
31
+ */
32
+ public static synchronized void setAdapterMetadata(Map<String, ?> metadata) {
33
+ MODEL_TO_TABLE.clear();
34
+ MODEL_FIELDS.clear();
35
+ RELATION_MAP.clear();
36
+ if (metadata == null) {
37
+ return;
38
+ }
39
+
40
+ putStringMap(MODEL_TO_TABLE, first(metadata, "model_to_table", "modelToTable"));
41
+
42
+ Map<String, ?> rawFields = first(metadata, "model_fields", "modelFields");
43
+ if (rawFields != null) {
44
+ for (Map.Entry<String, ?> entry : rawFields.entrySet()) {
45
+ MODEL_FIELDS.put(entry.getKey(), readFields(entry.getValue()));
46
+ }
47
+ }
48
+
49
+ Map<String, ?> rawRelations = first(metadata, "relation_map", "relationMap", "RELATION_MAP");
50
+ if (rawRelations != null) {
51
+ for (Map.Entry<String, ?> entry : rawRelations.entrySet()) {
52
+ Object value = entry.getValue();
53
+ if (!(value instanceof Map)) {
54
+ continue;
55
+ }
56
+ Map<String, RelationDef> forModel = new LinkedHashMap<String, RelationDef>();
57
+ for (Map.Entry<?, ?> rel : ((Map<?, ?>) value).entrySet()) {
58
+ if (!(rel.getValue() instanceof Map)) {
59
+ continue;
60
+ }
61
+ Map<?, ?> defn = (Map<?, ?>) rel.getValue();
62
+ forModel.put(
63
+ String.valueOf(rel.getKey()),
64
+ new RelationDef(
65
+ string(defn, "modelName", "model_name", "model"),
66
+ string(defn, "relationType", "relation_type", "type"),
67
+ string(defn, "foreignKey", "foreign_key"),
68
+ string(defn, "localKey", "local_key")));
69
+ }
70
+ RELATION_MAP.put(entry.getKey(), forModel);
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The model's name as the metadata keys it, accepting a different capitalisation.
77
+ *
78
+ * <p>The generated client registers tables under their PascalCase name while the metadata
79
+ * keys them in camelCase. A plain lookup would find no columns at all and silently skip
80
+ * {@code isId} — never generating the primary key.
81
+ */
82
+ public static String resolveModelKey(String modelName) {
83
+ if (modelName == null || modelName.isEmpty()) {
84
+ return modelName;
85
+ }
86
+ if (MODEL_TO_TABLE.containsKey(modelName) || MODEL_FIELDS.containsKey(modelName)) {
87
+ return modelName;
88
+ }
89
+ String camel = Character.toLowerCase(modelName.charAt(0)) + modelName.substring(1);
90
+ if (MODEL_TO_TABLE.containsKey(camel) || MODEL_FIELDS.containsKey(camel)) {
91
+ return camel;
92
+ }
93
+ String lower = modelName.toLowerCase();
94
+ if (MODEL_TO_TABLE.containsKey(lower) || MODEL_FIELDS.containsKey(lower)) {
95
+ return lower;
96
+ }
97
+ return modelName;
98
+ }
99
+
100
+ /** The table name a model maps to, or the model name when the schema used none. */
101
+ public static String resolveTable(String modelName) {
102
+ String key = resolveModelKey(modelName);
103
+ String table = MODEL_TO_TABLE.get(key);
104
+ return table == null ? modelName : table;
105
+ }
106
+
107
+ public static List<FieldMeta> fieldsFor(String modelName) {
108
+ List<FieldMeta> fields = MODEL_FIELDS.get(resolveModelKey(modelName));
109
+ if (fields == null) {
110
+ fields = MODEL_FIELDS.get(modelName);
111
+ }
112
+ return fields == null ? Collections.<FieldMeta>emptyList() : fields;
113
+ }
114
+
115
+ public static Map<String, RelationDef> relationsFor(String modelName) {
116
+ Map<String, RelationDef> relations = RELATION_MAP.get(modelName);
117
+ if (relations == null) {
118
+ relations = RELATION_MAP.get(resolveModelKey(modelName));
119
+ }
120
+ return relations == null ? Collections.<String, RelationDef>emptyMap() : relations;
121
+ }
122
+
123
+ /** A snapshot of the registered table map, for callers that want to inspect it. */
124
+ public static Map<String, String> modelToTable() {
125
+ synchronized (Metadata.class) {
126
+ return Collections.unmodifiableMap(new LinkedHashMap<String, String>(MODEL_TO_TABLE));
127
+ }
128
+ }
129
+
130
+ private static List<FieldMeta> readFields(Object value) {
131
+ List<FieldMeta> fields = new ArrayList<FieldMeta>();
132
+ if (!(value instanceof List)) {
133
+ return fields;
134
+ }
135
+ for (Object entry : (List<?>) value) {
136
+ if (!(entry instanceof Map)) {
137
+ continue;
138
+ }
139
+ Map<?, ?> field = (Map<?, ?>) entry;
140
+ fields.add(
141
+ new FieldMeta(
142
+ string(field, "name"),
143
+ string(field, "type"),
144
+ string(field, "sql", "sqlType"),
145
+ bool(field, "isOptional", "optional"),
146
+ bool(field, "hasDefault", "default"),
147
+ bool(field, "isId", "id"),
148
+ string(field, "description")));
149
+ }
150
+ return fields;
151
+ }
152
+
153
+ private static void putStringMap(Map<String, String> target, Object value) {
154
+ if (!(value instanceof Map)) {
155
+ return;
156
+ }
157
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
158
+ target.put(String.valueOf(entry.getKey()), entry.getValue() == null ? null : String.valueOf(entry.getValue()));
159
+ }
160
+ }
161
+
162
+ @SuppressWarnings("unchecked")
163
+ private static Map<String, ?> first(Map<String, ?> source, String... keys) {
164
+ for (String key : keys) {
165
+ Object value = source.get(key);
166
+ if (value instanceof Map) {
167
+ return (Map<String, ?>) value;
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ private static String string(Map<?, ?> source, String... keys) {
174
+ for (String key : keys) {
175
+ Object value = source.get(key);
176
+ if (value != null) {
177
+ return String.valueOf(value);
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ private static boolean bool(Map<?, ?> source, String... keys) {
184
+ for (String key : keys) {
185
+ Object value = source.get(key);
186
+ if (value instanceof Boolean) {
187
+ return (Boolean) value;
188
+ }
189
+ if (value instanceof String) {
190
+ return Boolean.parseBoolean((String) value);
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+ }
@@ -0,0 +1,26 @@
1
+ package an5.adapters.base;
2
+
3
+ /**
4
+ * One relation of a model, as emitted by the generator into the metadata module.
5
+ *
6
+ * <p>{@code relationType} is {@code "one"} or {@code "many"}. The two sides of the join
7
+ * are named the way the foreign key is stored: {@code foreignKey} is the column on the
8
+ * related model that points back, {@code localKey} the column on this model it points to.
9
+ */
10
+ public final class RelationDef {
11
+ public final String modelName;
12
+ public final String relationType;
13
+ public final String foreignKey;
14
+ public final String localKey;
15
+
16
+ public RelationDef(String modelName, String relationType, String foreignKey, String localKey) {
17
+ this.modelName = modelName;
18
+ this.relationType = relationType == null || relationType.isEmpty() ? "many" : relationType;
19
+ this.foreignKey = foreignKey;
20
+ this.localKey = localKey;
21
+ }
22
+
23
+ public boolean isMany() {
24
+ return "many".equals(relationType);
25
+ }
26
+ }